Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This code is licensed under Apache 2.0
#
# Run pytest tests for the backend. Done on every push & pr
# so its not necessary to run on deploy
# CI/CD workflow for backend CI and full-stack deployment

on:
push:
branches:
- main
pull_request:

jobs:
# continueous integration test job
# runs on every push and pull request via pytest
# NOTE: only tests the backend
test:
runs-on: ubuntu-latest

Expand All @@ -30,3 +34,16 @@ jobs:
- name: Run tests
run: |
PYTHONPATH=backend pytest -q backend/tests


# deploy to raspberry pi 5
# only deploy on pushes to main, and only after tests have passed
deploy:
runs-on: self-hosted
needs: test
if: github.ref == 'refs/heads/main' # only deploy on pushes to main

steps:
- uses: actions/checkout@v6
- run: docker compose pull
- run: docker compose up -d --build
20 changes: 0 additions & 20 deletions .github/workflows/deploy.yml

This file was deleted.

20 changes: 20 additions & 0 deletions README
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ Links
* GitHub Repository: https://github.com/lachlanharrisdev/readme
* Use the app: https://readme.lachlanharris.dev

Get Started
-----------

1) Clone the repository

+----
| git clone https://github.com/lachlanharrisdev/readme
| cd readme
+----

2) Using a text editor, update the appropriate environment variables in `docker-compose.yml`, including SECRET_KEY

3) Run the application

+----
| docker compose up -d
+----

The application will be reachable at `localhost:3000`

-------
This project is delivered under the Apache 2.0 License.

Expand Down
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# JWT

# openssl rand -hex 32
SECRET_KEY=a3603d4c1350de7cdddffb5556f379ff7e3ac7265f9bd11448188040a2ad7cf1
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=300
11 changes: 9 additions & 2 deletions backend/README
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,21 @@ Run locally
| docker compose up -d db
+----

2) Install backend dependencies and start the API:
2) Clone the env and fill out

+----
| cd backend
| cp .env.example .env.local
| nano .env.local
+----

3) Install dependencies and start the API:

+----
| python -m venv .venv
| source .venv/bin/activate
| pip install -r requirements.txt
| uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
| uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 --env-file .env.local
+----

Run with Docker Compose
Expand Down
Empty file removed backend/app/__init__.py
Empty file.
Empty file removed backend/app/api/__init__.py
Empty file.
Empty file removed backend/app/api/v1/__init__.py
Empty file.
16 changes: 16 additions & 0 deletions backend/app/api/v1/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This project is licensed under Apache 2.0
#
# src/api/v1/api.py
# Primary API router for V1. This file can be considered the
# "main" file for the API

from fastapi import APIRouter

from .auth import endpoints
from . import health

api_router = APIRouter()
api_router.include_router(endpoints.router, prefix="/auth", tags=["auth"])

api_router.include_router(health.router, prefix="/health", tags=["health"])
98 changes: 98 additions & 0 deletions backend/app/api/v1/auth/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This project is licensed under Apache 2.0
#
# src/api/v1/auth/auth.py
# Handles authentication logic

from .config import *

import jwt

from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from typing import Annotated


# Database interaction
# --------------------


def get_user(db, username: str):
if username in db:
user_dict = db[username]
return UserInDB(**user_dict)


# Password hashing / verification
# -------------------------------


def verify_password(plain_password, hashed_password):
return password_hash.verify(plain_password, hashed_password)


def get_password_hash(password):
return password_hash.hash(password)


DUMMY_HASH = password_hash.hash("dummypassword")


def authenticate_user(fake_db, username: str, password: str):
user = get_user(fake_db, username)
if not user:
verify_password(password, DUMMY_HASH)
return False
if not verify_password(password, user.hashed_password):
return False
return user


# JWT token creation / verification
# ---------------------------------


def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt


# Self utilities
# --------------


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except InvalidTokenError:
raise credentials_exception
user = get_user(fake_users_db, username=token_data.username)
if user is None:
raise credentials_exception
return user


async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
63 changes: 63 additions & 0 deletions backend/app/api/v1/auth/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This project is licensed under Apache 2.0
#
# src/api/v1/auth/env.py
# Manages authentication-specific env variables & constants

import os

from dotenv import load_dotenv

from fastapi.security import OAuth2PasswordBearer
from pwdlib import PasswordHash
from pydantic import BaseModel

# Environment variable loading
# ----------------------------
load_dotenv()

SECRET_KEY = os.getenv(
"SECRET_KEY", "8f70b2b1dd185be4d29bbfeeba2f98b588b6653c857d5d29bc77fd7e14b8dcc9"
)
ALGORITHM = os.getenv("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "300"))

# Constants
# ---------

password_hash = PasswordHash.recommended()

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")

fake_users_db = {
"lachlanharris": {
"username": "lachlanharris",
"full_name": "Lachlan Harris",
"email": "contact@lachlanharris.dev",
"hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$wagCPXjifgvUFBzq4hqe3w$CYaIb8sB+wtD+Vu/P4uod1+Qof8h+1g7bbDlBID48Rc", # "secret"
"disabled": False,
}
}

# Types
# -----


class Token(BaseModel):
access_token: str
token_type: str


class TokenData(BaseModel):
username: str | None = None


class User(BaseModel):
username: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None


class UserInDB(User):
hashed_password: str
75 changes: 75 additions & 0 deletions backend/app/api/v1/auth/endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This project is licensed under Apache 2.0
#
# src/api/v1/auth.py
# JWT Authentication API endpoints according to the V1 specification

from .config import *
from .auth import *

import jwt

from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from typing import Annotated

router = APIRouter()


# JWT-specific endpoints
# ----------------------


@router.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
"""
Request a JWT access token by providing a username and password

Requires:
username: string
password: string

Response:
access_token: string
token_type: string ("bearer")

Errors:
401 Unauthorized: If the username or password is incorrect
"""
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return Token(access_token=access_token, token_type="bearer")


# Utilities
# ---------


@router.get("/me")
async def read_users_me(
current_user: Annotated[User, Depends(get_current_active_user)],
) -> User:
"""
Get the current authenticated user's information

Authorization: true

Response:
username: string
email: string
full_name: string
disabled: boolean
"""
return current_user
Loading
Loading