first commit
This commit is contained in:
commit
6e354ba041
109 changed files with 12110 additions and 0 deletions
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
116
backend/app/routers/auth.py
Normal file
116
backend/app/routers/auth.py
Normal 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.")
|
||||
97
backend/app/routers/collab.py
Normal file
97
backend/app/routers/collab.py
Normal 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)
|
||||
159
backend/app/routers/documents.py
Normal file
159
backend/app/routers/documents.py
Normal 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")
|
||||
40
backend/app/routers/health.py
Normal file
40
backend/app/routers/health.py
Normal 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",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue