Skip to content
Open
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
41 changes: 41 additions & 0 deletions backend/app/api/v1/apikeys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from typing import List

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.dependencies import get_current_user, get_db
from app.crud import apikey as crud_apikey
from app.models.user import User
from app.schemas.apikey import APIKeyCreated, APIKeyOut

router = APIRouter(prefix="/apikeys", tags=["API Keys"])


@router.post("", response_model=APIKeyCreated, status_code=status.HTTP_201_CREATED)
async def create_api_key(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
key, db_key = await crud_apikey.create_api_key(db, current_user)
return APIKeyCreated(id=db_key.id, prefix=db_key.prefix, created_at=db_key.created_at, revoked=db_key.revoked, key=key)


@router.get("", response_model=List[APIKeyOut])
async def list_api_keys(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
keys = await crud_apikey.list_api_keys(db, current_user)
return keys


@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_api_key(
api_key_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
success = await crud_apikey.revoke_api_key(db, current_user, api_key_id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
return None
41 changes: 41 additions & 0 deletions backend/app/api/v1/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from datetime import datetime, timedelta
import secrets

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.dependencies import get_current_user, get_db, verify_api_key
from app.crud import user as crud_user
from app.models.user import User
from app.schemas.auth import Message, VerificationToken

router = APIRouter()


@router.post(
"/auth/request-verification",
response_model=Message,
dependencies=[Depends(verify_api_key)],
)
async def request_verification(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if current_user.email_verified:
return {"detail": "Email already verified"}
token = secrets.token_urlsafe(16)
expires = datetime.utcnow() + timedelta(hours=1)
await crud_user.set_verification_token(db, current_user, token, expires)
# In a real app, send the token via email
return {"detail": token}


@router.post("/auth/verify", response_model=Message)
async def verify_email(token_in: VerificationToken, db: AsyncSession = Depends(get_db)):
user = await crud_user.verify_user_email(db, token_in.token)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired token",
)
return {"detail": "Email verified"}
48 changes: 45 additions & 3 deletions backend/app/api/v1/tasks.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.dependencies import get_current_user, get_db, verify_api_key
from app.crud import task as crud
from app.models.user import User
from app.schemas.task import TaskCreate, TaskOut, TaskStatusUpdate, TaskUpdate
from app.schemas.task import (
TaskBulkRequest,
TaskBulkResponse,
TaskCreate,
TaskOut,
TaskStatus,
TaskStatusBulkUpdate,
TaskStatusUpdate,
TaskUpdate,
)

router = APIRouter(prefix="/tasks", tags=["Tasks"], dependencies=[Depends(verify_api_key)])

Expand All @@ -20,10 +29,23 @@ async def create_task(

@router.get("/", response_model=list[TaskOut], status_code=status.HTTP_200_OK)
async def list_tasks(
status: TaskStatus | None = None,
q: str | None = None,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
sort: str = Query("desc", pattern="^(asc|desc)$"),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await crud.get_tasks_for_user(db, user.id)
return await crud.get_tasks_for_user(
db,
user.id,
status=status,
q=q,
page=page,
limit=limit,
sort=sort,
)


@router.get("/{task_id}", response_model=TaskOut, status_code=status.HTTP_200_OK)
Expand Down Expand Up @@ -74,3 +96,23 @@ async def delete_task(
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
await crud.delete_task(db, task)


@router.post("/bulk", response_model=TaskBulkResponse, status_code=status.HTTP_200_OK)
async def bulk_tasks(
payload: TaskBulkRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
created = []
if payload.create:
created = await crud.create_tasks_bulk(
db, user.id, [task.model_dump() for task in payload.create]
)

updated = []
if payload.update_status:
updates = [(u.id, u.status) for u in payload.update_status]
updated = await crud.update_tasks_status_bulk(db, user.id, updates)

return TaskBulkResponse(created=created, updated=updated)
87 changes: 83 additions & 4 deletions backend/app/api/v1/users.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.dependencies import get_db
from app.core.dependencies import get_current_user, get_db, verify_api_key
from app.crud import user as crud_user
from app.schemas.user import UserCreate, UserOut
from app.models.user import User
from app.schemas.user import UserCreate, UserOut, UserUpdate

router = APIRouter()

Expand All @@ -17,6 +18,84 @@
async def signup(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
existing = await crud_user.get_user_by_username(db, user_in.username)
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken")
user = await crud_user.create_user(db, user_in.username, user_in.password)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken"
)
if user_in.email:
email_existing = await crud_user.get_user_by_email(db, user_in.email)
if email_existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already taken"
)
user = await crud_user.create_user(
db,
user_in.username,
user_in.password,
email=user_in.email,
display_name=user_in.display_name,
)
return user


@router.get(
"/users/me",
response_model=UserOut,
dependencies=[Depends(verify_api_key)],
status_code=status.HTTP_200_OK,
)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user


@router.put(
"/users/me",
response_model=UserOut,
dependencies=[Depends(verify_api_key)],
status_code=status.HTTP_200_OK,
)
async def update_me(
update: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
data = update.model_dump(exclude_unset=True)
if "username" in data:
existing = await crud_user.get_user_by_username(db, data["username"])
if existing and existing.id != current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken"
)
if "email" in data:
existing = await crud_user.get_user_by_email(db, data["email"])
if existing and existing.id != current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already taken"
Comment on lines +61 to +72

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Skip email uniqueness check when clearing address

Both update_me and patch_me run a uniqueness check whenever the payload contains an email key, even when the value is null. Because get_user_by_email is then called with None, the query matches the first user that already has a NULL email (which is common because signups default to no email). The existing.id != current_user.id branch fires and returns 400 "Email already taken", making it impossible for a user to remove their email address while any other user also lacks one. The check should only run when a non-NULL email is supplied.

Useful? React with 👍 / 👎.

)
return await crud_user.update_user(db, current_user, data)


@router.patch(
"/users/me",
response_model=UserOut,
dependencies=[Depends(verify_api_key)],
status_code=status.HTTP_200_OK,
)
async def patch_me(
update: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
data = update.model_dump(exclude_unset=True)
if "username" in data:
existing = await crud_user.get_user_by_username(db, data["username"])
if existing and existing.id != current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken"
)
if "email" in data:
existing = await crud_user.get_user_by_email(db, data["email"])
if existing and existing.id != current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already taken"
)
return await crud_user.update_user(db, current_user, data)
35 changes: 23 additions & 12 deletions backend/app/core/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.auth import verify_access_token
from app.core.config import settings
from app.crud import apikey as crud_apikey
from app.crud.user import get_user_by_username
from app.db.session import async_session
from app.models.user import User
Expand All @@ -22,17 +22,6 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
yield session


# ---------------------------- #
# Dependency: API Key Check
# ---------------------------- #
def verify_api_key(x_api_key: str = Depends(api_key_header)):
if not x_api_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API Key header missing")
if x_api_key != settings.HTTP_API_KEY:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid API Key")
return x_api_key


# ---------------------------- #
# Dependency: Get Current User
# ---------------------------- #
Expand All @@ -45,3 +34,25 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession
detail="User not found",
)
return user


# ---------------------------- #
# Dependency: API Key Check
# ---------------------------- #
async def verify_api_key(
x_api_key: str = Depends(api_key_header),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if not x_api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API Key header missing",
)
api_key = await crud_apikey.verify_api_key(db, current_user, x_api_key)
if not api_key:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid API Key",
)
return api_key
46 changes: 46 additions & 0 deletions backend/app/crud/apikey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from datetime import datetime
from secrets import token_urlsafe
from typing import List, Optional

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.security import hash_password, verify_password
from app.models.apikey import APIKey
from app.models.user import User


async def create_api_key(db: AsyncSession, user: User) -> tuple[str, APIKey]:
key = token_urlsafe(32)
hashed = hash_password(key)
db_key = APIKey(user_id=user.id, hashed_key=hashed, prefix=key[:8])
db.add(db_key)
await db.commit()
await db.refresh(db_key)
return key, db_key


async def list_api_keys(db: AsyncSession, user: User) -> List[APIKey]:
result = await db.execute(select(APIKey).where(APIKey.user_id == user.id))
return result.scalars().all()


async def verify_api_key(db: AsyncSession, user: User, raw_key: str) -> Optional[APIKey]:
result = await db.execute(
select(APIKey).where(APIKey.user_id == user.id, APIKey.revoked.is_(False))
)
for api_key in result.scalars().all():
if verify_password(raw_key, api_key.hashed_key):
api_key.last_used_at = datetime.utcnow()
await db.commit()
return api_key
return None


async def revoke_api_key(db: AsyncSession, user: User, api_key_id: int) -> bool:
api_key = await db.get(APIKey, api_key_id)
if not api_key or api_key.user_id != user.id:
return False
api_key.revoked = True
await db.commit()
return True
Loading