diff --git a/.cursor/rules/docs.mdc b/.cursor/rules/docs.mdc new file mode 100644 index 0000000..4b13faf --- /dev/null +++ b/.cursor/rules/docs.mdc @@ -0,0 +1,11 @@ +--- +description: Documentation rules +alwaysApply: false +--- + +When documenting this project, follow these rules: + +1. For TS, use JSDoc. +2. When something is self-explanatory or is a constant for a HTML element, don't document it. +3. Do NOT change the structure of the code, only add documentation. +4. When filling in the author field, say that you are Cursor. \ No newline at end of file diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc new file mode 100644 index 0000000..6791c5c --- /dev/null +++ b/.cursor/rules/ui.mdc @@ -0,0 +1,8 @@ +--- +alwaysApply: true +--- + +When you work with UI: + +1. Use MDUI components as HTML elements +2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML. \ No newline at end of file diff --git a/.gitignore b/.gitignore index f0cb9db..a7c6771 100644 --- a/.gitignore +++ b/.gitignore @@ -376,7 +376,7 @@ pyrightconfig.json # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) -instance +data .vite *.db package-lock.json \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index 4476f9a..9b7a6ae 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routes import account, messaging +from routes import account, messaging, profile # Инициализация FastAPI app = FastAPI(title="PixelChat") @@ -17,4 +17,5 @@ app.add_middleware( # Routes app.include_router(account.router) -app.include_router(messaging.router) \ No newline at end of file +app.include_router(messaging.router) +app.include_router(profile.router) \ No newline at end of file diff --git a/backend/constants.py b/backend/constants.py index d9a47c3..e3e8bb7 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -1,4 +1,4 @@ -DATABASE_URL = "sqlite:///./pixelchat.db" -JWT_SECRET_KEY = "pixelchat-jwt-secret" +DATABASE_URL = "sqlite:///./data/database.db" +JWT_SECRET_KEY = "fromchat-jwt-secret" JWT_ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_HOURS = 24 \ No newline at end of file diff --git a/backend/db.py b/backend/db.py index 834a613..1708283 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,6 +1,10 @@ +import os from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine from constants import DATABASE_URL +# Ensure data directory exists +os.makedirs("data", exist_ok=True) + engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) \ No newline at end of file diff --git a/backend/dependencies.py b/backend/dependencies.py index 7dd7199..f1b47ab 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -19,7 +19,7 @@ def get_db(): def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) -): +) -> User: token = credentials.credentials payload = verify_token(token) if not payload: diff --git a/backend/models.py b/backend/models.py index c193247..50271d4 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, text from sqlalchemy.orm import relationship from datetime import datetime from db import engine @@ -15,6 +15,8 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, nullable=False, index=True) password_hash = Column(String(200), nullable=False) + profile_picture = Column(String(255), nullable=True) + bio = Column(Text, nullable=True) online = Column(Boolean, default=False) last_seen = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now) @@ -29,8 +31,11 @@ class Message(Base): timestamp = Column(DateTime, default=datetime.now) user_id = Column(Integer, ForeignKey("user.id"), nullable=False) is_read = Column(Boolean, default=False) + reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True) + is_edited = Column(Boolean, default=False) author = relationship("User", back_populates="messages") + reply_to = relationship("Message", remote_side=[id]) # Pydantic модели @@ -49,6 +54,36 @@ class SendMessageRequest(BaseModel): content: str +class EditMessageRequest(BaseModel): + content: str + + +class ReplyMessageRequest(BaseModel): + content: str + reply_to_id: int + + +class DeleteMessageRequest(BaseModel): + message_id: int + + +class UpdateBioRequest(BaseModel): + bio: str + + +class UserProfileResponse(BaseModel): + id: int + username: str + profile_picture: str | None + bio: str | None + online: bool + last_seen: datetime + created_at: datetime + + class Config: + from_attributes = True + + class MessageResponse(BaseModel): id: int content: str @@ -56,6 +91,7 @@ class MessageResponse(BaseModel): is_author: bool is_read: bool username: str + profile_picture: str | None class Config: from_attributes = True diff --git a/backend/requirements.txt b/backend/requirements.txt index 158eacb..326b790 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,4 +3,6 @@ fastapi[standard]>=0.116.1 pydantic>=2.11.7 sqlalchemy>=2.0.43 bcrypt>=4.3.0 -websockets>=15.0.1 \ No newline at end of file +websockets>=15.0.1 +Pillow>=10.0.0 +python-multipart>=0.0.6 \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 2e93e1b..220f273 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db -from models import Message, SendMessageRequest, User +from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -15,22 +15,19 @@ def convert_message(msg: Message) -> dict: "content": msg.content, "timestamp": msg.timestamp.isoformat(), "is_read": msg.is_read, - "username": msg.author.username + "is_edited": msg.is_edited, + "username": msg.author.username, + "profile_picture": msg.author.profile_picture, + "reply_to": convert_message(msg.reply_to) if msg.reply_to else None } -async def get_messages_inner(db: Session): - messages = db.query(Message).order_by(Message.timestamp.asc()).all() - messages_data = [] - for msg in messages: - messages_data.append(convert_message(msg)) - - return { - "status": "success", - "messages": messages_data - } - -async def send_message_inner(request: SendMessageRequest, current_user: User, db: Session): +@router.post("/send_message") +async def send_message( + request: SendMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): if not request.content.strip(): raise HTTPException( status_code=400, @@ -49,18 +46,94 @@ async def send_message_inner(request: SendMessageRequest, current_user: User, db return {"status": "success", "message": convert_message(new_message)} -@router.post("/send_message") -async def send_message( - request: SendMessageRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - return await send_message_inner(request, current_user, db) - @router.get("/get_messages") async def get_messages(db: Session = Depends(get_db)): - return await get_messages_inner(db) + messages = db.query(Message).order_by(Message.timestamp.asc()).all() + + messages_data = [] + for msg in messages: + messages_data.append(convert_message(msg)) + + return { + "status": "success", + "messages": messages_data + } + + +@router.put("/edit_message/{message_id}") +async def edit_message( + message_id: int, + request: EditMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + message = db.query(Message).filter(Message.id == message_id).first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + if message.user_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only edit your own messages") + + if not request.content.strip(): + raise HTTPException(status_code=400, detail="Message content cannot be empty") + + message.content = request.content.strip() + message.is_edited = True + + db.commit() + db.refresh(message) + + return {"status": "success", "message": convert_message(message)} + + +@router.delete("/delete_message/{message_id}") +async def delete_message( + message_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + message = db.query(Message).filter(Message.id == message_id).first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + if message.user_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only delete your own messages") + + db.delete(message) + db.commit() + + return {"status": "success", "message_id": message_id} + + +@router.post("/reply_message") +async def reply_message( + request: ReplyMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Check if the message being replied to exists + original_message = db.query(Message).filter(Message.id == request.reply_to_id).first() + if not original_message: + raise HTTPException(status_code=404, detail="Original message not found") + + if not request.content.strip(): + raise HTTPException(status_code=400, detail="No content provided") + + new_message = Message( + content=request.content.strip(), + user_id=current_user.id, + timestamp=datetime.now(), + reply_to_id=request.reply_to_id + ) + + db.add(new_message) + db.commit() + db.refresh(new_message) + + return {"status": "success", "message": convert_message(new_message)} class MessaggingSocketManager: @@ -95,7 +168,7 @@ class MessaggingSocketManager: if not current_user: raise HTTPException(401) - await websocket.send_json({"type": type, "data": await get_messages_inner(current_user, db)}) + await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) except HTTPException as e: await self.send_error(websocket, type, e) elif type == "sendMessage": @@ -106,7 +179,57 @@ class MessaggingSocketManager: request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) - response = await send_message_inner(request, current_user, db) + response = await send_message(request, current_user, db) + await self.broadcast({ + "type": "newMessage", + "data": response["message"] + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "editMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + message_id = data["data"]["message_id"] + request: EditMessageRequest = EditMessageRequest.model_validate(data["data"]) + + response = await edit_message(message_id, request, current_user, db) + await self.broadcast({ + "type": "messageEdited", + "data": response["message"] + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "deleteMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + message_id = data["data"]["message_id"] + response = await delete_message(message_id, current_user, db) + await self.broadcast({ + "type": "messageDeleted", + "data": {"message_id": message_id} + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "replyMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + request: ReplyMessageRequest = ReplyMessageRequest.model_validate(data["data"]) + response = await reply_message(request, current_user, db) await self.broadcast({ "type": "newMessage", "data": response["message"] diff --git a/backend/routes/profile.py b/backend/routes/profile.py new file mode 100644 index 0000000..c1fa26f --- /dev/null +++ b/backend/routes/profile.py @@ -0,0 +1,144 @@ +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from PIL import Image +import os +import uuid +import io + +from dependencies import get_db, get_current_user +from models import User, UpdateBioRequest, UserProfileResponse + +router = APIRouter() + +# Create uploads directory if it doesn't exist +PROFILE_PICTURES_DIR = Path("data/uploads/pfp") + +os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) + +@router.post("/upload-profile-picture") +async def upload_profile_picture( + profile_picture: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Upload and process a profile picture + """ + # Validate file type + if not profile_picture.content_type.startswith('image/'): + raise HTTPException(status_code=400, detail="File must be an image") + + # Validate file size (max 5MB) + if profile_picture.size > 5 * 1024 * 1024: + raise HTTPException(status_code=400, detail="File size must be less than 5MB") + + try: + # Read and process the image + image_data = await profile_picture.read() + + # Open image with PIL + image = Image.open(io.BytesIO(image_data)) + + # Convert to RGB if necessary + if image.mode != 'RGB': + image = image.convert('RGB') + + # Resize to a reasonable size (200x200) + image.thumbnail((200, 200), Image.Resampling.LANCZOS) + + # Generate unique filename + filename = f"{current_user.id}_{uuid.uuid4().hex}.jpg" + filepath = os.path.join(PROFILE_PICTURES_DIR, filename) + + # Save the processed image + image.save(filepath, 'JPEG', quality=85) + + # Update user's profile picture in database + profile_picture_url = f"/api/profile-picture/{filename}" + current_user.profile_picture = profile_picture_url + db.commit() + + return { + "message": "Profile picture uploaded successfully", + "profile_picture_url": profile_picture_url + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}") + +@router.get("/profile-picture/{filename}") +async def get_profile_picture(filename: str): + """ + Serve profile picture files + """ + filepath = os.path.join(PROFILE_PICTURES_DIR, filename) + + if not os.path.exists(filepath): + raise HTTPException(status_code=404, detail="Profile picture not found") + + from fastapi.responses import FileResponse + return FileResponse(filepath, media_type="image/jpeg") + +@router.get("/user/profile") +async def get_user_profile( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get current user's profile information + """ + return { + "id": current_user.id, + "username": current_user.username, + "profile_picture": current_user.profile_picture, + "bio": current_user.bio, + "online": current_user.online, + "last_seen": current_user.last_seen, + "created_at": current_user.created_at + } + + +@router.put("/user/bio") +async def update_user_bio( + request: UpdateBioRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Update current user's bio + """ + if len(request.bio) > 500: # Limit bio to 500 characters + raise HTTPException(status_code=400, detail="Bio must be 500 characters or less") + + current_user.bio = request.bio.strip() + db.commit() + + return { + "message": "Bio updated successfully", + "bio": current_user.bio + } + + +@router.get("/user/{username}") +async def get_user_by_username( + username: str, + db: Session = Depends(get_db) +): + """ + Get user profile by username + """ + user = db.query(User).filter(User.username == username).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return UserProfileResponse( + id=user.id, + username=user.username, + profile_picture=user.profile_picture, + bio=user.bio, + online=user.online, + last_seen=user.last_seen, + created_at=user.created_at + ) diff --git a/frontend/index.html b/frontend/index.html index d63394a..5f5a6e8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,7 +21,7 @@