diff --git a/.github/actions/cc-title/action.yml b/.github/actions/cc-title/action.yml new file mode 100644 index 000000000..3784e6af6 --- /dev/null +++ b/.github/actions/cc-title/action.yml @@ -0,0 +1,11 @@ +name: Pull request title check +description: Check if PR title matches conventional commit specification +runs: + using: "composite" + steps: + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Match title to regex + shell: bash + run: python3 ./.github/actions/cc-title/check_title.py "${{ github.event.pull_request.title }}" diff --git a/.github/actions/cc-title/check_title.py b/.github/actions/cc-title/check_title.py new file mode 100644 index 000000000..fa4f5c981 --- /dev/null +++ b/.github/actions/cc-title/check_title.py @@ -0,0 +1,17 @@ +import re as re +from sys import argv + +pattern = re.compile( + "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)" + "{1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)" +) + +if len(argv) != 2: + print("Wrong number of arguments!") + exit(1) +if re.search(pattern, argv[1]) is not None: + print("Title matched!") + exit(0) +else: + print("Title does not match!") + exit(1) diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml new file mode 100644 index 000000000..46fe7ac38 --- /dev/null +++ b/.github/actions/setup-env/action.yml @@ -0,0 +1,13 @@ +name: Setup environment +description: Setup the environment used for testing and building flutter apps +runs: + using: "composite" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - run: pip install --upgrade pip + shell: bash + - run: pip install -r api/requirements.txt + shell: bash diff --git a/.github/workflows/analyze-and-test.yml b/.github/workflows/analyze-and-test.yml new file mode 100644 index 000000000..e7534e619 --- /dev/null +++ b/.github/workflows/analyze-and-test.yml @@ -0,0 +1,52 @@ +name: Code Maintenance + +on: + pull_request: + branches: [main] + + workflow_dispatch: + +jobs: + detect-secrets: + name: No committed secrets + runs-on: [ubuntu-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install detect-secrets + run: pip install detect-secrets + - name: Scan for secrets + run: detect-secrets scan --baseline .secrets.baseline + prettier-check: + name: Prettier formatted files + runs-on: [ubuntu-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: 20 + - name: Install prettier + run: npm install -g prettier + - name: Run prettier against files + run: prettier -c . + analyze-code: + name: No code smells + runs-on: [ubuntu-latest] + defaults: + run: + working-directory: ./api + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Setup environment + uses: ./.github/actions/setup-env + - name: Lint code + uses: chartboost/ruff-action@v1 + - name: Check code formatting + uses: psf/black@stable + with: + options: "--check" diff --git a/.github/workflows/check-pull-request-title.yml b/.github/workflows/check-pull-request-title.yml new file mode 100644 index 000000000..8f5689e54 --- /dev/null +++ b/.github/workflows/check-pull-request-title.yml @@ -0,0 +1,17 @@ +name: Conventional Commits + +on: + pull_request: + types: [edited, opened] + branches: [main] + + workflow_dispatch: + +jobs: + check-title-format: + name: PR title matches spec + runs-on: [ubuntu-latest] + steps: + - uses: actions/checkout@v4 + - name: Check if PR title matches regex + uses: ./.github/actions/cc-title diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..11433d89f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +env +venv +.vscode +*.db +*.env +__pycache__ +*.sqlite +.ruff_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..f342f78e8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +repos: + # Prettier + - repo: https://github.com/pre-commit/mirrors-prettier + rev: "v4.0.0-alpha.8" # Use the sha or tag you want to point at + hooks: + - id: prettier + types_or: ["markdown", "python", "yaml"] + args: ["--no-cache"] + # Black formatter + - repo: https://github.com/psf/black + rev: 24.1.0 + hooks: + - id: black + # Ruff linter + - repo: https://github.com/charliermarsh/ruff-pre-commit + # Ruff version. + rev: "v0.1.14" + hooks: + - id: ruff + # Detect secrets + - repo: https://github.com/Yelp/detect-secrets + rev: "v1.4.0" + hooks: + - id: detect-secrets + args: ["--baseline", ".secrets.baseline"] + exclude: package.lock.json + + # Check newlines, yaml is valid + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + exclude: ".hbs$" diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 000000000..f3113d4a5 --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,112 @@ +{ + "version": "1.4.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": {}, + "generated_at": "2024-01-26T10:53:20Z" +} diff --git a/README.md b/README.md index 2994dc5b8..f088408be 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,52 @@ # PWP SPRING 2024 + # PROJECT NAME + # Group information -* Student 1. Name and email -* Student 2. Name and email -* Student 3. Name and email -* Student 4. Name and email -__Remember to include all required documentation and HOWTOs, including how to create and populate the database, how to run and test the API, the url to the entrypoint and instructions on how to setup and run the client__ +- Student 1. Janne Kerola janne.kerola@oulu.fi +- Student 2. Emilia Pyyny epyyny20@student.oulu.fi +- Student 3. Errafay Amine aerrafay19@student.oulu.fi + +**Remember to include all required documentation and HOWTOs, including how to create and populate the database, how to run and test the API, the url to the entrypoint and instructions on how to setup and run the client** + +# Pre-commit + +We use [pre-commit](https://pre-commit.com) to ensure code quality. + +```shell +# Install pre-commit with pip +pip3 install pre-commit +# Run this to initialize pre-commit in the repo +cd /path/to/repo +pre-commit install +``` + +# Development + +This repository is a monorepo, containing all needed components for the course. + +API directory contains the flask backend. + +# Usage + +Easiest way to get this up and running is to use podman/docker: + +```shell +# create .env file in the root directory +touch .env +# fill in the details as such +DB_PASSWORD= +DB_USER= + +podman-compose up -d +# Or docker +docker compose up -d + +# then make requests to localhost:3000 +curl -X GET localhost:3000 +``` +# Examine database contents with adminer +Navigate to [http://localhost:8080](http://localhost:8080) and login with the above details. Server should be _'db'_ and database should be _'postgres'_. diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 000000000..03b308c5a --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,4 @@ +env +__pycache__ +README.md +venv diff --git a/api/README.md b/api/README.md new file mode 100644 index 000000000..0c6eadf1c --- /dev/null +++ b/api/README.md @@ -0,0 +1,90 @@ +# Flask API + +## How does it work? + +```mermaid +--- +title: Authentication +--- +sequenceDiagram + participant c as Client + participant a as API + Note over a,c: Account creation + c->>a: POST /auth/register {"username": "password":} + a-->>c: 201 Created + + note over a,c: Login event + c->>a: POST /auth/login {"username": "password": } + a-->>c: 200 OK {"access_token": } + + note over a,c: Accessing a route that requires authentication + c->>a: GET /auth/profile Headers: {Authorization: Bearer } + a-->>c: 200 OK {"username": ... } + + +``` + +## Setup + +Create a **.env** file in the _api_ directory and fill it with the following, replacing < things > with whatever: + +```shell +API_USERNAME= +API_PASSWORD= +SECRET_TOKEN= +DB_PASSWORD= +DB_USER= +DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/db?schema=public +``` + +Make sure a database is running by executing `docker compose up -d database` or `podman-compose up -d database` in the **project root directory**. + +Change directory to _api/_, and run the application with: + +```sh +# create a virtualenv +python3 -m venv venv +source venv/bin/activate + +# Install the required packages +pip install -r requirements.txt + +# Generate the models based on prisma/schema.prisma +prisma generate + +# Create tables +prisma db push + +# Run the application with +flask run +``` + +# Playing around with db models [Deadline 2] + +```shell +# Start the database +docker-compose up -d +cd api + +# create a virtualenv +python3 -m venv venv +source venv/bin/activate + +# Install the required packages +pip install -r requirements.txt + +# Generate the client +prisma generate + +# Load the database dump into the container +podman exec -i pwp-2024_db_1 psql -U -d postgres < database_dump.sql + +# Run the python3 interpreter +python3 +>>> from prisma.models import User, Poll, PollItem +>>> User.prisma().find_first(where={"username": "testuser"}) +# More CRUD operations and explanations here +# https://www.prisma.io/docs/orm/prisma-client/queries/crud +``` + +You can monitor the database contents with adminer, at [localhost:8080](http://localhost:8080). diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/app.py b/api/app.py new file mode 100644 index 000000000..fb7aabfc2 --- /dev/null +++ b/api/app.py @@ -0,0 +1,16 @@ +from flask import Flask +from api.controllers.auth import auth +from api.database import connect_to_db +from api.middleware.error_handler import handle_exception +from werkzeug.exceptions import HTTPException + + +def create_app() -> Flask: + app = Flask(__name__) + app.register_blueprint(auth) + app.register_error_handler(HTTPException, handle_exception) + return app + + +connect_to_db() +app = create_app() diff --git a/api/config.py b/api/config.py new file mode 100644 index 000000000..488721714 --- /dev/null +++ b/api/config.py @@ -0,0 +1,19 @@ +from dotenv import load_dotenv +from dataclasses import dataclass +from os import getenv +from datetime import timedelta + +load_dotenv() + + +@dataclass() +class Config: + secret: str = getenv("SECRET_TOKEN") + jwt_expires_in = timedelta(minutes=30) + + def __post_init__(self): + if self.secret is None or len(self.secret) == 0: + raise Exception("Couldn't load secret from env") + + +config = Config() diff --git a/api/controllers/__init__.py b/api/controllers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/controllers/auth.py b/api/controllers/auth.py new file mode 100644 index 000000000..44b5e9225 --- /dev/null +++ b/api/controllers/auth.py @@ -0,0 +1,39 @@ +from flask import request, make_response, Blueprint, Response +from api.models.auth_dtos import RegisterDto, LoginDto +from prisma.errors import UniqueViolationError +from api.services.jwt import JWTService +from api.middleware.authguard import requires_authentication +from prisma.models import User +from werkzeug.exceptions import Unauthorized, BadRequest + +auth = Blueprint("auth", __name__) + + +@auth.route("/auth/register", methods=["POST"]) +def register(): + register_dto = RegisterDto.from_json(request.json) + try: + User.prisma().create(data=register_dto.to_insertable()) + except UniqueViolationError: + raise BadRequest("username not unique") + return Response(status=201) + + +@auth.route("/auth/login", methods=["POST"]) +def login(): + login_dto = LoginDto.from_json(request.json) + user = User.prisma().find_first(where={"username": login_dto.username}) + if user and login_dto.verify(user.hash): + payload = {"sub": user.id, "username": user.username} + token = JWTService.create_token(payload) + return make_response({"access_token": token}) + else: + raise Unauthorized("unauthorized request") + + +@auth.route("/auth/profile", methods=["GET"]) +@requires_authentication +def profile(user: User): + user_dict = user.model_dump() + del user_dict["hash"] + return user_dict diff --git a/api/database.py b/api/database.py new file mode 100644 index 000000000..226490025 --- /dev/null +++ b/api/database.py @@ -0,0 +1,10 @@ +from prisma import Prisma, register + + +db = Prisma() + +register(db) + + +def connect_to_db(): + db.connect() diff --git a/api/database_dump.sql b/api/database_dump.sql new file mode 100644 index 000000000..80a810397 --- /dev/null +++ b/api/database_dump.sql @@ -0,0 +1,162 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 16.1 +-- Dumped by pg_dump version 16.1 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: Role; Type: TYPE; Schema: public; Owner: postgres +-- + +CREATE TYPE public."Role" AS ENUM ( + 'USER', + 'ADMIN' +); + + +ALTER TYPE public."Role" OWNER TO postgres; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: Poll; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public."Poll" ( + id text NOT NULL, + private boolean DEFAULT false NOT NULL, + title text NOT NULL, + description text, + created timestamp(3) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + expires timestamp(3) without time zone NOT NULL, + "multipleAnswers" boolean DEFAULT false NOT NULL, + "userId" text NOT NULL +); + + +ALTER TABLE public."Poll" OWNER TO postgres; + +-- +-- Name: PollItem; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public."PollItem" ( + id text NOT NULL, + description text, + votes integer DEFAULT 0 NOT NULL, + "pollId" text NOT NULL +); + + +ALTER TABLE public."PollItem" OWNER TO postgres; + +-- +-- Name: User; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public."User" ( + id text NOT NULL, + role public."Role" DEFAULT 'USER'::public."Role" NOT NULL, + username text NOT NULL, + hash text NOT NULL, + "firstName" text, + "lastName" text, + email text +); + + +ALTER TABLE public."User" OWNER TO postgres; + +-- +-- Data for Name: Poll; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public."Poll" (id, private, title, description, created, expires, "multipleAnswers", "userId") FROM stdin; +clser9ceq0001uwwqln6ruc5q f election vote for the next president 2024-02-09 14:41:03.506 2024-03-10 16:41:03.49 f clser971d0000zlg0ka2ojisg +\. + + +-- +-- Data for Name: PollItem; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public."PollItem" (id, description, votes, "pollId") FROM stdin; +clser9cgw0003uwwq7fq4l6cd stubb 0 clser9ceq0001uwwqln6ruc5q +clser9ch50005uwwq3bc09b02 haavisto 0 clser9ceq0001uwwqln6ruc5q +clser9chc0007uwwq1bdgcamv putin 0 clser9ceq0001uwwqln6ruc5q +\. + + +-- +-- Data for Name: User; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public."User" (id, role, username, hash, "firstName", "lastName", email) FROM stdin; +clser971d0000zlg0ka2ojisg USER testuser $argon2id$v=19$m=65536,t=3,p=4$wImcC+vKHzQUtv5/8DhWwg$lTKcUcxn2m+qT66KvBb/y6QgphuSiQgORWZf9jiOWA0 None None None +\. + + +-- +-- Name: PollItem PollItem_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public."PollItem" + ADD CONSTRAINT "PollItem_pkey" PRIMARY KEY (id); + + +-- +-- Name: Poll Poll_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public."Poll" + ADD CONSTRAINT "Poll_pkey" PRIMARY KEY (id); + + +-- +-- Name: User User_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public."User" + ADD CONSTRAINT "User_pkey" PRIMARY KEY (id); + + +-- +-- Name: User_username_key; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE UNIQUE INDEX "User_username_key" ON public."User" USING btree (username); + + +-- +-- Name: PollItem PollItem_pollId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public."PollItem" + ADD CONSTRAINT "PollItem_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES public."Poll"(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: Poll Poll_userId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public."Poll" + ADD CONSTRAINT "Poll_userId_fkey" FOREIGN KEY ("userId") REFERENCES public."User"(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- diff --git a/api/dockerfile b/api/dockerfile new file mode 100644 index 000000000..75a86ba31 --- /dev/null +++ b/api/dockerfile @@ -0,0 +1,16 @@ +FROM node:alpine + +# Install necessities +RUN apk upgrade +RUN apk add python3 py3-pip + +# Copy files +workdir api +copy . . +RUN pip install -r requirements.txt --break-system-packages + +# Generate client +RUN prisma generate + +# Run server +ENTRYPOINT ["waitress-serve", "app:app"] diff --git a/api/middleware/__init__.py b/api/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/middleware/authguard.py b/api/middleware/authguard.py new file mode 100644 index 000000000..cce047f0a --- /dev/null +++ b/api/middleware/authguard.py @@ -0,0 +1,27 @@ +from functools import wraps +from flask import request +from api.services.jwt import JWTService +from api.database import db +from werkzeug.exceptions import Unauthorized +from jwt.exceptions import DecodeError + + +def requires_authentication(f): + @wraps(f) + def parse_authorization(): + auth_type = None + token = None + try: + if "Authorization" in request.headers: + auth_type, token = request.headers["Authorization"].split(" ") + if not token or auth_type != "Bearer": + raise Unauthorized("unauthorized request") + data = JWTService.verify_token(token) + user = db.user.find_unique({"id": data["sub"]}) + if not user: + raise Unauthorized("unauthorized request") + except (ValueError, DecodeError): + raise Unauthorized("unauthorized request") + return f(user) + + return parse_authorization diff --git a/api/middleware/error_handler.py b/api/middleware/error_handler.py new file mode 100644 index 000000000..aabb24c99 --- /dev/null +++ b/api/middleware/error_handler.py @@ -0,0 +1,7 @@ +from flask import make_response +from werkzeug.exceptions import HTTPException + + +def handle_exception(e: HTTPException): + payload = {"error": e.name, "code": e.code, "description": e.description} + return make_response(payload), e.code diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/models/auth_dtos.py b/api/models/auth_dtos.py new file mode 100644 index 000000000..4c63a3022 --- /dev/null +++ b/api/models/auth_dtos.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass, asdict +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from werkzeug.exceptions import BadRequest +from api.models.base_dto import BaseDto + + +class _AuthBaseDto(BaseDto): + _ph = PasswordHasher() + + +@dataclass(frozen=True) +class RegisterDto(_AuthBaseDto): + """DTO for managing user signup""" + + username: str + password: str + email: str = None + firstName: str = None + lastName: str = None + + def generate_hash(self) -> str: + """Creates a salted hash of the password property.""" + return self._ph.hash(self.password) + + def to_insertable(self) -> dict: + """Converts the password into a salted hash and + returns the DTO as a dict.""" + user = {k: str(v) for k, v in asdict(self).items()} + user["hash"] = self.generate_hash() + del user["password"] + return user + + @staticmethod + def from_json(data: dict): + """Creates a new Dto from request.json + data: request.json + """ + RegisterDto.validate( + [ + ("username", str), + ("password", str), + ], + data, + ) + return RegisterDto( + username=data.get("username"), + password=data.get("password"), + email=data.get("email"), + firstName=data.get("firstName"), + lastName=data.get("lastName"), + ) + + +@dataclass(frozen=True) +class LoginDto(_AuthBaseDto): + """DTO for managing user authentication""" + + username: str + password: str + + def verify(self, hash: str) -> bool: + """Compares the login password to [hash] + hash: str + returns True if match, else raise BadRequest + """ + try: + return self._ph.verify(hash, self.password) + except VerifyMismatchError: + raise BadRequest("Invalid credentials") + + @staticmethod + def from_json(data: dict): + """Creates a new DTO from json + data: request.json + """ + LoginDto.validate( + [ + ("username", str), + ("password", str), + ], + data, + ) + + return LoginDto( + username=data.get("username"), + password=data.get("password"), + ) diff --git a/api/models/base_dto.py b/api/models/base_dto.py new file mode 100644 index 000000000..f37fe0ee0 --- /dev/null +++ b/api/models/base_dto.py @@ -0,0 +1,30 @@ +from dataclasses import asdict +from typing import List +from werkzeug.exceptions import BadRequest + + +class BaseDto: + """All other DTOs inherit stuff from this one.""" + + def to_insertable(self) -> dict: + """Convert the DTO into a dict for use with Prisma""" + return {k: str(v) for k, v in asdict(self).items()} + + @staticmethod + def validate(props: List[tuple], data: dict): + """Validate the data properties + data: request.json + props: ex: [('username', str)] + + raises BadRequest error if invalid + """ + errors = [] + for prop in props: + k, t = prop + val = data.get(k) + if val is None: + errors.append(f"Missing property '{k}'") + elif not isinstance(val, t): + errors.append(f"Property '{k}' not of type str") + if len(errors) > 0: + raise BadRequest(errors) diff --git a/api/models/poll_dtos.py b/api/models/poll_dtos.py new file mode 100644 index 000000000..cf02351f9 --- /dev/null +++ b/api/models/poll_dtos.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from datetime import datetime +from api.models.base_dto import BaseDto + + +@dataclass(frozen=True) +class PollItemDto(BaseDto): + """DTO for managing pollItems""" + + pollId: str + description: str + votes: int = 0 + + @staticmethod + def from_json(data: dict): + """Create a new DTO from json + data: request.json + """ + PollItemDto.validate([("pollId", str)], data) + + return PollItemDto( + pollId=data.get("pollId"), + description=data.get("description"), + votes=0, + ) + + +@dataclass(frozen=True) +class PollDto(BaseDto): + """DTO for managing polls""" + + userId: str + title: str + description: str + expires: datetime + private: bool = False + multipleAnswers: bool = False + + @staticmethod + def from_json(data: dict): + """Create a new DTO from json + data: request.json + """ + PollDto.validate( + [ + ("userId", str), + ("title", str), + ("expires", datetime), + ], + data, + ) + + return PollDto( + userId=data.get("userId"), + description=data.get("description"), + title=data.get("title"), + expires=data.get("expires"), + multipleAnswers=data.get("multipleAnswers"), + private=data.get("private"), + ) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma new file mode 100644 index 000000000..7969d834b --- /dev/null +++ b/api/prisma/schema.prisma @@ -0,0 +1,47 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-py" + interface = "sync" + recursive_type_depth = 5 +} + +model User { + id String @id @default(cuid()) + role Role @default(USER) + username String @unique + hash String + firstName String? + lastName String? + email String? + polls Poll[] +} + +model Poll { + id String @id @default(cuid()) + private Boolean @default(false) + title String + description String? + created DateTime @default(now()) + expires DateTime + multipleAnswers Boolean @default(false) + items PollItem[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String +} + +model PollItem { + id String @id @default(cuid()) + description String? + votes Int @default(0) + poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade) + pollId String +} + +enum Role { + USER + ADMIN +} diff --git a/api/requests/README.md b/api/requests/README.md new file mode 100644 index 000000000..2a1afcfe3 --- /dev/null +++ b/api/requests/README.md @@ -0,0 +1,22 @@ +# How to use .http files to make requests + +[httpyac documentation](https://httpyac.github.io/guide/request.html) + +# httpyac vscode extension + +or install the [vscode extension for httpyac.](https://marketplace.visualstudio.com/items?itemName=anweber.vscode-httpyac) + +1. In vscode, open the http file +2. Click the small "send" above the request you want to execute + +# httpyac CLI tool + +Install [httpyac](https://httpyac.github.io/). + +```sh +# Install +npm install -g httpyac + +# Use +httpyac requests/auth.http +``` diff --git a/api/requests/auth.http b/api/requests/auth.http new file mode 100644 index 000000000..fbf08cbe5 --- /dev/null +++ b/api/requests/auth.http @@ -0,0 +1,29 @@ +@host=http://localhost:5000 +@username={{API_USERNAME}} +@password={{API_PASSWORD}} + +### +# register +POST /auth/register +Content-Type: application/json + +{ + "username": "{{username}}", + "password": "{{password}}" +} + +### +# @name login +POST /auth/login +Content-Type: application/json + +{ + "username": "{{username}}", + "password": "{{password}}" +} + +### +# @ref login +GET /auth/profile +Content-Type: application/json +Authorization: Bearer {{login.access_token}} diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 000000000..861328389 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,29 @@ +annotated-types==0.6.0 +anyio==4.2.0 +argon2-cffi==23.1.0 +argon2-cffi-bindings==21.2.0 +asgiref==3.7.2 +blinker==1.7.0 +certifi==2024.2.2 +cffi==1.16.0 +click==8.1.7 +Flask==3.0.2 +h11==0.14.0 +httpcore==1.0.2 +httpx==0.26.0 +idna==3.6 +itsdangerous==2.1.2 +Jinja2==3.1.3 +MarkupSafe==2.1.4 +nodeenv==1.8.0 +prisma==0.12.0 +pycparser==2.21 +pydantic==2.6.0 +pydantic_core==2.16.1 +PyJWT==2.8.0 +python-dotenv==1.0.1 +sniffio==1.3.0 +tomlkit==0.12.3 +typing_extensions==4.9.0 +waitress==2.1.2 +Werkzeug==3.0.1 diff --git a/api/services/__init__.py b/api/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/services/jwt.py b/api/services/jwt.py new file mode 100644 index 000000000..278c77738 --- /dev/null +++ b/api/services/jwt.py @@ -0,0 +1,19 @@ +import jwt +from api.config import config +from datetime import datetime, timezone +from werkzeug.exceptions import Unauthorized + + +class JWTService: + @staticmethod + def create_token(payload: dict) -> str: + expires_in = datetime.now(tz=timezone.utc) + config.jwt_expires_in + + return jwt.encode({"exp": expires_in, **payload}, config.secret) + + @staticmethod + def verify_token(token: str) -> dict: + try: + return jwt.decode(token, config.secret, ["HS256"]) + except jwt.exceptions.ExpiredSignatureError: + raise Unauthorized("token expired") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..0fa7bad03 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + # Uncomment this to build the production server! + # server: + # build: api + # depends_on: + # - db + # ports: + # - 3000:3000 + # environment: + # SECRET_TOKEN: ${SECRET_TOKEN} + # DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/postgres?schema=public + + db: + image: docker.io/library/postgres:alpine + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_USER: ${DB_USER} + POSTGRES_DB: postgres + ports: + - 5432:5432 + adminer: + image: docker.io/library/adminer + ports: + - 8080:8080 diff --git a/meetings.md b/meetings.md index ed26b2baf..617ef2254 100644 --- a/meetings.md +++ b/meetings.md @@ -1,80 +1,115 @@ # Meetings notes ## Meeting 1. -* **DATE:** -* **ASSISTANTS:** + +- **DATE: 7th February 2024** +- **ASSISTANTS: Ivan Sanchez Milara** ### Minutes -*Summary of what was discussed during the meeting* + +Duration of the meeting was 30 minutes. The agenda of the meeting was to receive feedback for deliverable 1 - project plan. Several improvement points were suggested by the course staff. Based on the feedback the project plan focuses too much on technical concepts, while it should have focused more on explaining the underlying concept instead. Some parts that were too technical can be moved into deliverable 2. Another point that came up was that at some points the explanations provided should be expanded upon, or the explanation was not relevant to what was requested in the section. ### Action points -*List here the actions points discussed with assistants* +**Group information** +- List all group members on the table +**DL1. RESTTful API description** -## Meeting 2. -* **DATE:** -* **ASSISTANTS:** +RESTful API description -### Minutes -*Summary of what was discussed during the meeting* +- Focuses too much on technicalities. Focus more into motivating the API and explain it a bit more in detail. ✅ +- Wrong terminology. Avoid terms like login or public link that are more referred to applications. Authentication instead of registered users. ✅ (recheck this) +- Architecture not needed in this section. ✅ -### Action points -*List here the actions points discussed with assistants* +Main concepts and relations +- Make a new graph. Take example from lecture 2 slides - Forum Resource hierarchy. +- Old graph can be moved into deliverable 2. +- Focuses too much on technical concepts. **Focus should be on the main concepts and relations**. Explanation should be altered to be something similar to the second paragraph of this section (To create a poll...). +- Improve wording at places. Remove words like "database" etc. +API uses +- Must add external service. +- Auxiliary service - check Lovelace -## Meeting 3. -* **DATE:** -* **ASSISTANTS:** +Related work + +- Explain strawpoll better. +- Add example of a client, for example frontend. + +Update resources allocation if information is incorrect. + +## Meeting 2. + +- **DATE: 21st February 2024** +- **ASSISTANTS: Ivan Sanchez Milara** ### Minutes -*Summary of what was discussed during the meeting* + +Duration of the meeting was 25 minutes. The agenda of the meeting was to receive feedback for deliverable 2 - database design and implementation. At the start of the meeting we discussed about what tools will be utilized in the implementation. The application will be implemented using Python, while the client will be running on Node. The ORM being used is Prisma. PostgreSQL is used for the database, but assistant alternatively recommended using SQLite instead, as it can be easier to set up. The feedback from assistant was overall positive, and only a few changes were suggested. ### Action points -*List here the actions points discussed with assistants* +Database design +- `Role Enumeration` can be moved into `User model`. +- Improve `Restrictions` column's REGEX and make sure `schema.prisma` matches it. +Resources allocation -## Meeting 4. -* **DATE:** -* **ASSISTANTS:** +- Update Resources allocation table if information is not correct. + +## Meeting 3. + +- **DATE:** +- **ASSISTANTS:** ### Minutes -*Summary of what was discussed during the meeting* -### Action points -*List here the actions points discussed with assistants* +_Summary of what was discussed during the meeting_ +### Action points +_List here the actions points discussed with assistants_ +## Meeting 4. -## Midterm meeting -* **DATE:** -* **ASSISTANTS:** +- **DATE:** +- **ASSISTANTS:** ### Minutes -*Summary of what was discussed during the meeting* -### Action points -*List here the actions points discussed with assistants* +_Summary of what was discussed during the meeting_ +### Action points +_List here the actions points discussed with assistants_ +## Midterm meeting -## Final meeting -* **DATE:** -* **ASSISTANTS:** +- **DATE:** +- **ASSISTANTS:** ### Minutes -*Summary of what was discussed during the meeting* + +_Summary of what was discussed during the meeting_ ### Action points -*List here the actions points discussed with assistants* +_List here the actions points discussed with assistants_ + +## Final meeting +- **DATE:** +- **ASSISTANTS:** +### Minutes + +_Summary of what was discussed during the meeting_ + +### Action points +_List here the actions points discussed with assistants_