first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:44:15 +05:30
commit 6e354ba041
109 changed files with 12110 additions and 0 deletions

View file

@ -0,0 +1 @@

116
backend/app/routers/auth.py Normal file
View file

@ -0,0 +1,116 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.dependencies import (
generate_reset_token,
get_current_user,
reset_token_expiry,
)
from app.models.user import User
from app.schemas.user import (
ForgotPasswordRequest,
LoginRequest,
MessageResponse,
ResetPasswordRequest,
TokenResponse,
UserCreate,
UserResponse,
UserUpdate,
)
from app.services.user_service import (
authenticate_user,
create_password_reset_token,
create_user,
get_user_by_email,
get_user_by_username,
get_valid_reset_token,
update_user,
)
from app.utils.security import create_access_token, create_refresh_token, hash_password
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register(data: UserCreate, db: Session = Depends(get_db)) -> User:
if get_user_by_email(db, data.email.lower()):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
if get_user_by_username(db, data.username.lower()):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
return create_user(db, data)
@router.post("/login", response_model=TokenResponse)
def login(data: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
user = authenticate_user(db, data.username_or_email, data.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username/email or password",
)
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is inactive")
access_token = create_access_token(
user.email,
timedelta(days=7) if data.remember_me else None,
)
return TokenResponse(
access_token=access_token,
refresh_token=create_refresh_token(user.email),
user=UserResponse.model_validate(user),
)
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)) -> User:
return current_user
@router.patch("/me", response_model=UserResponse)
def update_me(
data: UserUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> User:
return update_user(db, current_user, data)
@router.post("/forgot-password", response_model=MessageResponse)
def forgot_password(data: ForgotPasswordRequest, db: Session = Depends(get_db)) -> MessageResponse:
user = get_user_by_email(db, data.email.lower())
if user is None:
return MessageResponse(message="If that email exists, a reset link has been sent.")
token = generate_reset_token()
create_password_reset_token(db, user, token, reset_token_expiry())
response = MessageResponse(message="If that email exists, a reset link has been sent.")
if settings.debug:
response.reset_token = token
return response
@router.post("/reset-password", response_model=MessageResponse)
def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)) -> MessageResponse:
reset = get_valid_reset_token(db, data.token)
if reset is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired reset token")
user = db.query(User).filter(User.id == reset.user_id).first()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.hashed_password = hash_password(data.new_password)
reset.used = True
db.commit()
return MessageResponse(message="Password reset successfully. You can now log in.")

View file

@ -0,0 +1,97 @@
from collections import defaultdict
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.mongo import get_mongo_db
from app.services.document_service import get_document
from app.services.user_service import get_user_by_email
from app.utils.security import verify_token
router = APIRouter()
rooms: dict[str, set[WebSocket]] = defaultdict(set)
presence: dict[str, dict[int, str]] = defaultdict(dict)
async def _user_from_token(token: str):
subject = verify_token(token, expected_type="access")
if not subject:
return None
db: Session = SessionLocal()
try:
return get_user_by_email(db, subject)
finally:
db.close()
@router.websocket("/ws/collab/{doc_id}")
async def collab_socket(websocket: WebSocket, doc_id: str) -> None:
token = websocket.query_params.get("token", "")
user = await _user_from_token(token)
if user is None:
await websocket.close(code=4401)
return
try:
mongo = get_mongo_db()
except RuntimeError:
await websocket.close(code=1013)
return
doc = await get_document(mongo, doc_id, user.id)
if doc is None:
await websocket.close(code=4403)
return
await websocket.accept()
rooms[doc_id].add(websocket)
presence[doc_id][user.id] = user.full_name or user.username
async def broadcast(payload: dict, skip: WebSocket | None = None) -> None:
dead: list[WebSocket] = []
for client in rooms[doc_id]:
if client is skip:
continue
try:
await client.send_json(payload)
except Exception:
dead.append(client)
for client in dead:
rooms[doc_id].discard(client)
await broadcast(
{
"type": "presence",
"peers": [{"id": uid, "name": name} for uid, name in presence[doc_id].items()],
}
)
try:
while True:
message = await websocket.receive_json()
if message.get("type") == "content":
await broadcast(
{
"type": "content",
"content": message.get("content"),
"from": user.id,
},
skip=websocket,
)
except WebSocketDisconnect:
pass
finally:
rooms[doc_id].discard(websocket)
presence[doc_id].pop(user.id, None)
if rooms[doc_id]:
await broadcast(
{
"type": "presence",
"peers": [{"id": uid, "name": name} for uid, name in presence[doc_id].items()],
}
)
else:
rooms.pop(doc_id, None)
presence.pop(doc_id, None)

View file

@ -0,0 +1,159 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_mongo_db_dep
from app.models.user import User
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, ShareRequest
from app.services.document_service import (
add_member,
create_document,
delete_document,
get_document,
list_documents,
record_visit,
remove_member,
search_documents,
update_document,
)
from app.services.user_service import get_user_by_email_or_username
router = APIRouter(prefix="/documents", tags=["documents"])
@router.get("", response_model=list[DocumentResponse])
async def list_docs(
type: str | None = Query(None, description="Filter by document type"),
stead_id: str | None = Query(None, description="Filter by parent stead"),
starred: bool | None = Query(None, description="Filter starred projects"),
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> list[DocumentResponse]:
sort_field = "last_visited_at" if sort == "last_visited_at" else "updated_at"
docs = await list_documents(
db,
current_user.id,
doc_type=type,
stead_id=stead_id,
starred=starred,
sort=sort_field,
)
return [DocumentResponse(**doc) for doc in docs]
@router.get("/search", response_model=list[DocumentResponse])
async def search_docs(
q: str = Query("", min_length=0),
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> list[DocumentResponse]:
docs = await search_documents(db, current_user.id, q)
return [DocumentResponse(**doc) for doc in docs]
@router.get("/{doc_id}", response_model=DocumentResponse)
async def get_doc(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await get_document(db, doc_id, current_user.id)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
return DocumentResponse(**doc)
@router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
async def create_doc(
data: DocumentCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
created_by = current_user.full_name or current_user.username
doc = await create_document(db, current_user.id, created_by, data)
return DocumentResponse(**doc)
@router.patch("/{doc_id}", response_model=DocumentResponse)
async def update_doc(
doc_id: str,
data: DocumentUpdate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await update_document(db, doc_id, current_user.id, data)
if doc is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Document not found or you do not have edit access",
)
return DocumentResponse(**doc)
@router.post("/{doc_id}/visit", response_model=DocumentResponse)
async def visit_doc(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await record_visit(db, doc_id, current_user.id)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
return DocumentResponse(**doc)
@router.post("/{doc_id}/share", response_model=DocumentResponse)
async def share_doc(
doc_id: str,
data: ShareRequest,
current_user: User = Depends(get_current_user),
mongo: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
sql: Session = Depends(get_db),
) -> DocumentResponse:
identifier = data.email_or_username.strip().lower()
user = get_user_by_email_or_username(sql, identifier)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="You already own this document")
doc = await add_member(
mongo,
doc_id,
current_user.id,
{
"user_id": user.id,
"name": user.full_name or user.username,
"email": user.email,
},
data.role,
)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can share this document")
return DocumentResponse(**doc)
@router.delete("/{doc_id}/share/{user_id}", response_model=DocumentResponse)
async def unshare_doc(
doc_id: str,
user_id: int,
current_user: User = Depends(get_current_user),
mongo: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
doc = await remove_member(mongo, doc_id, current_user.id, user_id)
if doc is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can update sharing")
return DocumentResponse(**doc)
@router.delete("/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_doc(
doc_id: str,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> None:
deleted = await delete_document(db, doc_id, current_user.id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")

View file

@ -0,0 +1,40 @@
from fastapi import APIRouter
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from app.config import settings
from app.database import engine
from app.mongo import mongo_client, mongo_db
router = APIRouter(tags=["health"])
@router.get("/health")
async def health_check() -> dict[str, str]:
db_status = "disconnected"
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
db_status = "sqlite" if settings.is_sqlite else "connected"
except OperationalError:
pass
mongo_status = "disconnected"
if mongo_client is not None and mongo_db is not None:
try:
await mongo_client.admin.command("ping")
mongo_status = "connected"
except Exception:
mongo_status = "disconnected"
overall = "ok" if db_status != "disconnected" and mongo_status == "connected" else "degraded"
return {
"status": overall,
"service": "silos-api",
"database": db_status,
"mongodb": mongo_status,
"hint": ""
if mongo_status == "connected"
else "Start MongoDB with: docker compose up mongo -d",
}