diff --git a/.github/workflows/.go-docker.yml b/.github/workflows/.go-docker.yml deleted file mode 100644 index a2524da..0000000 --- a/.github/workflows/.go-docker.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: ChatApp - -# Trigger workflow on push or pull request to main branch -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build-test-deploy: - name: Build, Test & Docker - runs-on: ubuntu-latest - - steps: - # 1. Checkout repository - - name: Checkout code - uses: actions/checkout@v3 - - # 2. Set up Go - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: 1.21 - - # 3. Cache Go modules for speed - - name: Cache Go modules - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - # 4. Install dependencies - - name: Install dependencies - run: go mod tidy - - # 5. Build binaries - - name: Build server and client - run: | - go build -o chat-server ./server.go - go build -o chat-client ./client.go - - - # 6. Build Docker images - - name: Build Docker images (Compose v2) - run: docker compose -f docker-compose.yml build diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..c4c9c2a --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,74 @@ +name: CI/CD + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend: + name: Backend Build & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + cache: true + cache-dependency-path: backend/go.sum + + - name: Download dependencies + run: go mod download + + - name: Verify formatting + run: test -z "$(gofmt -l .)" + + - name: Run tests + run: go test ./... + + - name: Build server + run: go build -v ./cmd/server + + frontend: + name: Frontend Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build frontend + run: npm run build + + docker: + name: Docker Compose Build + runs-on: ubuntu-latest + needs: [backend, frontend] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build Docker images + run: docker compose -f docker-compose.yml build diff --git a/.gitignore b/.gitignore index a0ff092..d28c3e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,18 @@ # Binary chat-server +backend/chat-server # Database chat.db +backend/chat.db # Uploads uploads/ +backend/uploads/ + +# Frontend +frontend/node_modules/ +frontend/dist/ # IDE .vscode/ diff --git a/LOCATION_SHARING.md b/LOCATION_SHARING.md deleted file mode 100644 index b982f78..0000000 --- a/LOCATION_SHARING.md +++ /dev/null @@ -1,20 +0,0 @@ -# Location Sharing Feature - -## Implementation - -### Backend (Go) -- Added `latitude` and `longitude` fields to Message struct -- Updated database schema to include location columns -- Modified message INSERT queries to save location data -- Updated SELECT queries to retrieve location data - -### Frontend (JavaScript) -- Add button to share location -- Use HTML5 Geolocation API -- Display location as Google Maps link or map preview - -### Usage -1. Click location button (📍) in chat input area -2. Browser will request permission to access location -3. Location will be sent as message with coordinates -4. Recipient can click to view on map diff --git a/README.md b/README.md index c0c43b6..d13cce4 100644 --- a/README.md +++ b/README.md @@ -1,393 +1,284 @@ -# TCP Chat Application - -> **WARNING - CRITICAL INFRASTRUCTURE** -> This repository is designed to facilitate communication for Iranian citizens during protests and internet shutdowns. It operates on domestic servers to maintain connectivity when external internet access is restricted or censored. Use responsibly and ensure secure deployment in sensitive environments. - -A feature-rich chat application with a WhatsApp-like UI, built with Go and Server-Sent Events for real-time communication. - -## Screenshots - -![Chat Application](assets/chat.png) -![Chat Application](assets/all.png) - -*Modern WhatsApp-like interface with real-time messaging, groups, and media sharing* - ---- - -## Features - -### Authentication and Security -* **User Registration and Login** - Complete authentication system -* **Password Hashing** - Using SHA256 -* **Secure Sessions** - User management with SSE -* **User Blocking** - Block/unblock unwanted users -* **Mutual Contact System** - Two-way contact addition - -### Messaging -* **Private Chat** - One-on-one messaging with privacy controls -* **Group Chats** - Create and manage groups with members -* **Leave Group** - Exit from groups you no longer want to be in -* **File Sharing** - Send images, videos, and files -* **Voice Messages** - Record and send voice messages -* **Media Filtering** - Filter messages by type (images/files/audio) -* **Typing Indicator** - Shows when user is typing -* **Real-time Messaging** - Using Server-Sent Events -* **Message Blocking** - Blocked users cannot send you messages - -### User Management -* **Username Search** - Find and add users by username -* **Contact Management** - Add/remove contacts easily -* **Block List** - View and manage blocked users -* **Group Member Management** - Add members from your contacts - -### User Interface -* **WhatsApp-like Design** - Modern and familiar UI -* **Dark Mode** - Eye-comfortable dark theme -* **Responsive** - Works on desktop and mobile -* **Media Preview** - Display images and videos in chat -* **Audio Player** - Built-in player for voice messages -* **Media Filter Tabs** - Quick access to specific media types -* **Smooth Animations** - Professional user experience -* **Loading States** - Clear feedback during operations - -### Technical -* **SQLite Database** - Data storage -* **Server-Sent Events** - Real-time communications -* **RESTful API** - Modern architecture -* **Docker** - Ready for deployment -* **Offline Capability** - Works on local networks without internet - ---- +# ParsChat -## Project Structure +ParsChat is a full-stack real-time chat application with a Go backend and a React frontend. -``` -chatApp/ -├── cmd/ -│ └── server/ -│ └── main.go # Main server with SSE and API -├── public/ -│ ├── index.html # Web UI -│ └── app.js # Frontend logic -├── uploads/ # Uploaded files -├── chat.db # SQLite database -├── Dockerfile # Docker image -├── docker-compose.yml # Service configuration -├── go.mod # Go dependencies -└── README.md # This file -``` +## Overview ---- +- Backend: Go (`net/http`) + SQLite + JWT auth + SSE realtime stream +- Frontend: React + TypeScript + Zustand + Axios + Vite +- Realtime: Server-Sent Events for incoming messages, typing signals, and online/offline updates +- Storage: SQLite database (`chat.db`) and local upload storage (`uploads/`) -## Quick Start +## Key Features -### Prerequisites +- User authentication (register/login with JWT) +- One-to-one chat +- Group chat (create group, add/remove members, leave group) +- Typing indicators +- Online/offline presence +- Block / unblock users +- Media and file sharing (image, video, audio, generic files) +- Voice message recording from browser microphone +- Location sharing (latitude/longitude with map link) +- Emoji picker in composer +- Profile modal (view/edit username, full name, bio, avatar) +- Message reactions API support -* Go 1.21 or later -* Docker and Docker Compose (optional) -* Modern web browser +## Project Structure ---- +```text +ParsChat/ +├── backend/ +│ ├── cmd/server/main.go +│ ├── internal/ +│ │ ├── config/ +│ │ ├── controllers/ +│ │ ├── middleware/ +│ │ ├── models/ +│ │ ├── routes/ +│ │ ├── server/ +│ │ ├── services/ +│ │ └── utils/ +│ ├── public/ +│ ├── uploads/ +│ ├── Dockerfile +│ ├── docker-compose.yml +│ └── go.mod +├── frontend/ +│ ├── src/ +│ │ ├── components/ +│ │ ├── features/ +│ │ ├── hooks/ +│ │ ├── lib/ +│ │ ├── services/ +│ │ └── types.ts +│ ├── Dockerfile +│ ├── docker-compose.yml +│ ├── package.json +│ └── vite.config.ts +├── API_AUDIT.md +├── LOCATION_SHARING.md +└── docker-compose.yml +``` -## Running Locally +## Tech Stack -### 1. Install Dependencies +### Backend +- Go 1.21 +- SQLite (`github.com/mattn/go-sqlite3`) +- JWT (`github.com/golang-jwt/jwt/v5`) +- SSE over HTTP (`/events`) -```bash -go mod download -``` +### Frontend +- React 18 +- TypeScript +- Zustand (state management) +- Axios (HTTP client) +- Vite (dev server and bundler) -### 2. Run Server +## Requirements -```bash -go run cmd/server/main.go -``` +- Go 1.21+ +- Node.js 20+ +- npm 9+ +- Docker + Docker Compose (optional) + +## Environment Variables -Server runs on `http://localhost:8080` +Backend reads these variables: -### 3. Open in Browser +- `PORT` (default: `8080`) +- `DB_PATH` (default: `./chat.db`) +- `JWT_SECRET` (default is development-only fallback) -Go to `http://localhost:8080` and: -1. Register or login -2. Search for other users -3. Start chatting! +Frontend (Vite) can use: ---- +- `VITE_PROXY_TARGET` (default: `http://localhost:8080`) -## Running with Docker +## Run Locally (Recommended for Development) -### Build and Run +### 1) Start backend ```bash -docker-compose up --build +cd backend +go mod tidy +go run ./cmd/server ``` -### Access the Application +Backend URL: `http://localhost:8080` -Go to `http://localhost:8080` +### 2) Start frontend -### Stop Services +Open a second terminal: ```bash -docker-compose down +cd frontend +npm install +npm run dev ``` ---- +Frontend URL: `http://localhost:5173` -## Usage Guide +Vite proxies `/api`, `/events`, and `/uploads` to the backend. -### Registration and Login +## Build Frontend for Backend Static Serving -1. Open the login page -2. Click "Register" -3. Enter username, full name, and password -4. After registration, login +If you build the frontend, backend can serve `frontend/dist` automatically. -### Starting a Private Chat - -1. Click the chat button `[C]` (New Chat) -2. **Option A - Search by Username:** - - Type a username in the search box - - Click "Search" - - Click "Add Contact" if found -3. **Option B - Select from List:** - - Browse all users below - - Click "Add" to add as contact - - Click on contact to start chatting - -### Blocking/Unblocking Users +```bash +cd frontend +npm install +npm run build +``` -1. Open a private chat -2. Click the block button `[X]` in chat header -3. To unblock, click the `[✓]` button +Then run backend as usual: -### Filtering Messages by Media Type +```bash +cd backend +go run ./cmd/server +``` -In private chats: -1. Use the filter tabs: **All | Images | Files | Audio** -2. View only specific types of shared media +## Docker -### Sending Voice Messages +### Run full stack from root -1. Press and hold the microphone button `[🎤]` -2. Record your voice message -3. Release to send automatically +```bash +docker compose up --build +``` -### Creating a Group +Services: -1. Click the group button `[G]` (New Group) -2. Enter group name -3. Select members from **your contacts** -4. Click "Create Group" +- Backend: `http://localhost:8080` +- Frontend: `http://localhost:5173` -### Leaving a Group +### Run only backend -1. Open a group chat -2. Click the `[Leave]` button in chat header -3. Confirm to leave the group +```bash +cd backend +docker compose up --build +``` -### Sending Media +### Run only frontend -1. Click the attachment button in the input area -2. Select an image or video -3. File is automatically uploaded and sent +```bash +cd frontend +docker compose up --build +``` -### Additional Features +### Stop containers -* **Search**: Use the search box to find contacts -* **Typing**: When you type, the other party sees it -* **Online Status**: See online status indicator +From whichever directory you started compose: ---- +```bash +docker compose down +``` -## API Endpoints +## API Summary -### Authentication +### Auth +- `POST /api/register` +- `POST /api/login` -* `POST /api/register` - Register new user -* `POST /api/login` - User login +### Users and Profile +- `GET /api/users` +- `GET /api/profile?userId=...` +- `POST /api/profile` -### Users and Contacts +### Messaging and Realtime +- `GET /api/messages` +- `POST /api/send` +- `POST /api/typing` +- `GET /events` -* `GET /api/users` - Get user list -* `GET /api/contacts?userId={id}` - Get user's contacts -* `POST /api/contacts` - Add contact (mutual) -* `DELETE /api/contacts` - Remove contact +### Uploads and Presence +- `POST /api/upload` +- `GET /api/online` ### Blocking - -* `POST /api/block` - Block a user -* `POST /api/unblock` - Unblock a user -* `GET /api/blocked?userId={id}` - Get blocked users list -* `GET /api/isblocked?blockerId={id}&blockedId={id}` - Check if blocked +- `POST /api/block` +- `POST /api/unblock` +- `GET /api/blocked` ### Groups +- `GET /api/groups` +- `POST /api/groups` +- `POST /api/group/add` +- `POST /api/group/remove` +- `POST /api/group/leave` +- `GET /api/group/members` -* `GET /api/groups?userId={id}` - Get user's groups -* `POST /api/groups` - Create new group -* `POST /api/groups/leave` - Leave a group +### Reads and Reactions +- `POST /api/messages/read` +- `POST /api/reactions/add` +- `POST /api/reactions/remove` +- `GET /api/reactions` -### Messages +### Key Exchange +- `POST /api/keys/save` +- `GET /api/keys/get` -* `GET /api/messages?userId={id}&contactId={id}` - Get private messages -* `GET /api/messages?userId={id}&contactId={id}&mediaFilter={type}` - Get filtered messages -* `GET /api/messages?userId={id}&groupId={id}` - Get group messages -* `POST /api/send` - Send message -* `POST /api/typing` - Send typing indicator -* `GET /events?userId={id}` - SSE connection +For detailed request/response shapes, check `API_AUDIT.md`. -### Media +## Database Notes -* `POST /api/upload` - Upload file (images/videos/audio/voice) -* `GET /uploads/{filename}` - Get uploaded file +Database schema is initialized automatically on startup. ---- +Current tables include: -## Database Schema +- `users` +- `groups` +- `group_members` +- `messages` +- `message_reads` +- `message_reactions` +- `blocked_users` +- `user_keys` -### users table -```sql -- id (INTEGER PRIMARY KEY) -- username (TEXT UNIQUE) -- full_name (TEXT) -- password (TEXT - SHA256 hash) -- created_at (DATETIME) -``` +The service also performs lightweight migration attempts for profile fields (`bio`, `avatar_url`) on startup. -### contacts table -```sql -- user_id (INTEGER) -- contact_id (INTEGER) -- added_at (DATETIME) -- PRIMARY KEY(user_id, contact_id) -``` +## Troubleshooting -### blocked_users table -```sql -- blocker_id (INTEGER) -- blocked_id (INTEGER) -- blocked_at (DATETIME) -- PRIMARY KEY(blocker_id, blocked_id) -``` +### `vite: not found` -### groups table -```sql -- id (INTEGER PRIMARY KEY) -- name (TEXT) -- creator_id (INTEGER) -- created_at (DATETIME) -``` +Install frontend dependencies first: -### group_members table -```sql -- group_id (INTEGER) -- user_id (INTEGER) -- joined_at (DATETIME) -``` - -### messages table -```sql -- id (INTEGER PRIMARY KEY) -- from_user (INTEGER) -- to_user (INTEGER, nullable) -- group_id (INTEGER, nullable) -- content (TEXT, nullable) -- media_url (TEXT, nullable) -- media_type (TEXT, nullable - image/video/file/audio/voice) -- timestamp (DATETIME) +```bash +cd frontend +npm install +npm run dev ``` ---- - -## Production Deployment - -### Environment Variables +### `Cannot find module 'zustand'` or other frontend package errors ```bash -PORT=8080 # Server port +cd frontend +rm -rf node_modules package-lock.json +npm install +npm run dev ``` -### Security Notes - -1. Use HTTPS in production -2. Enforce strong passwords -3. Implement rate limiting -4. Regular database backups -5. Input validation and sanitization - ---- - -## Development -x] ~~Voice messages~~ ✅ Completed -- [x] ~~User blocking~~ ✅ Completed -- [x] ~~Username search~~ ✅ Completed -- [x] ~~Media filtering~~ ✅ Completed -- [x] ~~Leave group~~ ✅ Completed -- [ ] JWT authentication -- [ ] End-to-end encryption -- [ ] Voice/video calls -- [ ] Push notifications -- [ ] Emoji reactions -- [ ] Location sharing -- [ ] Message read receipt - -### Dependencies - -* `github.com/mattn/go-sqlite3` - SQLite driver +### Port already in use ---- +- Change backend `PORT` +- Or stop the process currently using `8080` / `5173` -## TODO / Future Improvements +### Uploads not accessible -- [ ] JWT authentication -- [ ] End-to-end encryption -- [ ] Voice/video calls -- [ ] Push notifications -- [ ] Emoji reactions -- [ ] Location sharing -- [ ] Voice messages -- [ ] Custom themes -- [ ] Mobile app (React Native) +- Ensure backend is running +- Ensure `uploads/` exists and is writable +- Access files via `/uploads/` ---- +## Security Notes -## Contributing +- Default `JWT_SECRET` fallback is for development only. +- Set a strong `JWT_SECRET` in production. +- Consider running behind HTTPS and adding stricter auth checks for profile update ownership. -Contributions are welcome! Please: +## Related Docs -1. Fork the repository -2. Create a feature branch -3. Commit your changes -4. Push to the branch -5. Create a Pull Request - ---- +- `API_AUDIT.md`: API contracts and endpoint details +- `LOCATION_SHARING.md`: location-sharing behavior ## License -This project is released under the MIT License. - ---- - -## Ethical Use - -This tool is designed to help free communication during critical times. Please: - -* Use responsibly and legally -* Respect user privacy -* Avoid misuse -* Be aware of security requirements - ---- - -## Support - -For issues, questions, or feature requests: -* Create an Issue on GitHub -* Join discussions - ---- - -**Built for free communication** - - +No license file is currently included in this repository. diff --git a/Dockerfile b/backend/Dockerfile similarity index 65% rename from Dockerfile rename to backend/Dockerfile index ec47c04..deaf2b4 100644 --- a/Dockerfile +++ b/backend/Dockerfile @@ -1,33 +1,24 @@ -# Stage 1: Build FROM golang:1.21 AS builder WORKDIR /app RUN apt-get update && apt-get install -y gcc sqlite3 libsqlite3-dev -# Copy go.mod and go.sum for dependency caching COPY go.mod go.sum ./ RUN go mod download -# Copy all source files COPY . . - -# Build server binary RUN CGO_ENABLED=1 go build -o chat-server ./cmd/server -# Stage 2: Runtime FROM debian:bullseye-slim WORKDIR /app RUN apt-get update && apt-get install -y ca-certificates sqlite3 && rm -rf /var/lib/apt/lists/* -# Copy binaries from builder COPY --from=builder /app/chat-server . COPY --from=builder /app/public ./public RUN mkdir -p uploads -# Expose server port EXPOSE 8080 -# Default command (can override in Compose) -CMD ["./chat-server"] +CMD ["./chat-server"] \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..e6b92be --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "log" + + "github.com/username/tcp-chat/internal/config" + "github.com/username/tcp-chat/internal/server" +) + +func main() { + cfg := config.Load() + if err := server.Run(cfg); err != nil { + log.Fatal(err) + } +} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..8c2dcc7 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,16 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile + container_name: parschat-backend + ports: + - "8080:8080" + environment: + - PORT=8080 + - DB_PATH=/app/chat.db + - JWT_SECRET=change-this-in-production + volumes: + - ./uploads:/app/uploads + - ./chat.db:/app/chat.db + restart: unless-stopped \ No newline at end of file diff --git a/go.mod b/backend/go.mod similarity index 66% rename from go.mod rename to backend/go.mod index 3df6349..0156e42 100644 --- a/go.mod +++ b/backend/go.mod @@ -3,3 +3,5 @@ module github.com/username/tcp-chat go 1.21 require github.com/mattn/go-sqlite3 v1.14.19 + +require github.com/golang-jwt/jwt/v5 v5.2.1 diff --git a/go.sum b/backend/go.sum similarity index 50% rename from go.sum rename to backend/go.sum index 3042612..9d14c37 100644 --- a/go.sum +++ b/backend/go.sum @@ -1,2 +1,4 @@ +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..63bb9a3 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,32 @@ +package config + +import "os" + +type Config struct { + Port string + DBPath string + JWTSecret string +} + +func Load() Config { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./chat.db" + } + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + jwtSecret = "your-secret-key-change-in-production-12345" + } + + return Config{ + Port: port, + DBPath: dbPath, + JWTSecret: jwtSecret, + } +} diff --git a/backend/internal/controllers/controller.go b/backend/internal/controllers/controller.go new file mode 100644 index 0000000..42720c8 --- /dev/null +++ b/backend/internal/controllers/controller.go @@ -0,0 +1,840 @@ +package controllers + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/username/tcp-chat/internal/models" + "github.com/username/tcp-chat/internal/services" + "github.com/username/tcp-chat/internal/utils" +) + +type Controller struct { + Service *services.ChatService +} + +func NewController(service *services.ChatService) *Controller { + return &Controller{Service: service} +} + +func (c *Controller) RegisterHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + FullName string `json:"fullName"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.FullName) == "" || strings.TrimSpace(req.Password) == "" { + utils.WriteAPIError(w, http.StatusBadRequest, "username, fullName and password are required") + return + } + + hash := c.Service.HashPassword(req.Password) + result, err := c.Service.DB.Exec("INSERT INTO users (username, full_name, password) VALUES (?, ?, ?)", req.Username, req.FullName, hash) + if err != nil { + utils.WriteAPIError(w, http.StatusConflict, "username already exists") + return + } + + id, _ := result.LastInsertId() + utils.WriteJSON(w, http.StatusCreated, map[string]interface{}{ + "id": id, + "username": req.Username, + "fullName": req.FullName, + "bio": "", + "avatarUrl": "", + }) +} + +func (c *Controller) LoginHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" { + utils.WriteAPIError(w, http.StatusBadRequest, "username and password are required") + return + } + + var user models.User + var storedHash string + err := c.Service.DB.QueryRow("SELECT id, username, full_name, password, bio, avatar_url FROM users WHERE username = ?", req.Username).Scan(&user.ID, &user.Username, &user.FullName, &storedHash, &user.Bio, &user.AvatarURL) + if err != nil || c.Service.HashPassword(req.Password) != storedHash { + utils.WriteAPIError(w, http.StatusUnauthorized, "invalid credentials") + return + } + + token, err := c.Service.GenerateJWT(user.ID, user.Username) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to generate token") + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{ + "id": user.ID, + "username": user.Username, + "fullName": user.FullName, + "bio": user.Bio, + "avatarUrl": user.AvatarURL, + "token": token, + }) +} + +func (c *Controller) GetUsersHandler(w http.ResponseWriter, r *http.Request) { + rows, err := c.Service.DB.Query("SELECT id, username, full_name, bio, avatar_url FROM users ORDER BY username") + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "server error") + return + } + defer rows.Close() + + users := make([]models.User, 0) + for rows.Next() { + var user models.User + _ = rows.Scan(&user.ID, &user.Username, &user.FullName, &user.Bio, &user.AvatarURL) + users = append(users, user) + } + + utils.WriteJSON(w, http.StatusOK, users) +} + +func (c *Controller) ProfileHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + c.UpdateProfileHandler(w, r) + return + } + + c.GetProfileHandler(w, r) +} + +func (c *Controller) GetProfileHandler(w http.ResponseWriter, r *http.Request) { + userID, _ := strconv.Atoi(r.URL.Query().Get("userId")) + if userID == 0 { + utils.WriteAPIError(w, http.StatusBadRequest, "userId is required") + return + } + + var user models.User + err := c.Service.DB.QueryRow("SELECT id, username, full_name, bio, avatar_url FROM users WHERE id = ?", userID). + Scan(&user.ID, &user.Username, &user.FullName, &user.Bio, &user.AvatarURL) + if err != nil { + utils.WriteAPIError(w, http.StatusNotFound, "user not found") + return + } + + utils.WriteJSON(w, http.StatusOK, user) +} + +func (c *Controller) UpdateProfileHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID int `json:"userId"` + Username string `json:"username"` + FullName string `json:"fullName"` + Bio string `json:"bio"` + AvatarURL string `json:"avatarUrl"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + if req.UserID == 0 || strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.FullName) == "" { + utils.WriteAPIError(w, http.StatusBadRequest, "userId, username and fullName are required") + return + } + + _, err := c.Service.DB.Exec( + "UPDATE users SET username = ?, full_name = ?, bio = ?, avatar_url = ? WHERE id = ?", + req.Username, + req.FullName, + req.Bio, + req.AvatarURL, + req.UserID, + ) + if err != nil { + utils.WriteAPIError(w, http.StatusConflict, "failed to update profile") + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{ + "id": req.UserID, + "username": req.Username, + "fullName": req.FullName, + "bio": req.Bio, + "avatarUrl": req.AvatarURL, + }) +} + +func (c *Controller) GroupsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + c.CreateGroupHandler(w, r) + return + } + + c.GetGroupsHandler(w, r) +} + +func (c *Controller) CreateGroupHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + CreatorID int `json:"creatorId"` + MemberIDs []int `json:"memberIds"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + if strings.TrimSpace(req.Name) == "" || req.CreatorID == 0 { + utils.WriteAPIError(w, http.StatusBadRequest, "name and creatorId are required") + return + } + + tx, _ := c.Service.DB.Begin() + result, err := tx.Exec("INSERT INTO groups (name, creator_id) VALUES (?, ?)", req.Name, req.CreatorID) + if err != nil { + _ = tx.Rollback() + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to create group") + return + } + + groupID, _ := result.LastInsertId() + _, _ = tx.Exec("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", groupID, req.CreatorID) + for _, memberID := range req.MemberIDs { + _, _ = tx.Exec("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", groupID, memberID) + } + _ = tx.Commit() + + utils.WriteJSON(w, http.StatusCreated, map[string]interface{}{"id": groupID, "name": req.Name}) +} + +func (c *Controller) GetGroupsHandler(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Query().Get("userId") + if userID == "" { + utils.WriteAPIError(w, http.StatusBadRequest, "userId is required") + return + } + + rows, err := c.Service.DB.Query(` + SELECT DISTINCT g.id, g.name, g.creator_id + FROM groups g + JOIN group_members gm ON g.id = gm.group_id + WHERE gm.user_id = ? + ORDER BY g.name`, userID) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "server error") + return + } + defer rows.Close() + + groups := make([]models.Group, 0) + for rows.Next() { + var group models.Group + _ = rows.Scan(&group.ID, &group.Name, &group.Creator) + groups = append(groups, group) + } + + utils.WriteJSON(w, http.StatusOK, groups) +} + +func (c *Controller) GetMessagesHandler(w http.ResponseWriter, r *http.Request) { + userID, _ := strconv.Atoi(r.URL.Query().Get("userId")) + contactID, _ := strconv.Atoi(r.URL.Query().Get("contactId")) + groupID, _ := strconv.Atoi(r.URL.Query().Get("groupId")) + + if userID == 0 { + utils.WriteAPIError(w, http.StatusBadRequest, "userId is required") + return + } + if groupID == 0 && contactID == 0 { + utils.WriteAPIError(w, http.StatusBadRequest, "contactId or groupId is required") + return + } + + var ( + rows *sql.Rows + err error + ) + + if groupID > 0 { + rows, err = c.Service.DB.Query(` + SELECT id, from_user, to_user, group_id, content, media_url, media_type, + latitude, longitude, strftime('%s', timestamp) as ts + FROM messages + WHERE group_id = ? + ORDER BY timestamp ASC + LIMIT 100`, groupID) + } else { + rows, err = c.Service.DB.Query(` + SELECT id, from_user, to_user, group_id, content, media_url, media_type, + latitude, longitude, strftime('%s', timestamp) as ts + FROM messages + WHERE (from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?) + ORDER BY timestamp ASC + LIMIT 100`, userID, contactID, contactID, userID) + } + + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "server error") + return + } + defer rows.Close() + + messages := make([]models.Message, 0) + for rows.Next() { + var message models.Message + var id int + var toUser, groupIDVal sql.NullInt64 + var content, mediaURL, mediaType sql.NullString + var latitude, longitude sql.NullFloat64 + _ = rows.Scan(&id, &message.From, &toUser, &groupIDVal, &content, &mediaURL, &mediaType, &latitude, &longitude, &message.Timestamp) + + message.ID = id + message.Type = "message" + if toUser.Valid { + message.To = int(toUser.Int64) + } + if groupIDVal.Valid { + message.GroupID = int(groupIDVal.Int64) + } + if content.Valid { + message.Content = content.String + } + if mediaURL.Valid { + message.MediaURL = mediaURL.String + } + if mediaType.Valid { + message.MediaType = mediaType.String + } + if latitude.Valid { + lat := latitude.Float64 + message.Latitude = &lat + } + if longitude.Valid { + lon := longitude.Float64 + message.Longitude = &lon + } + + reactRows, _ := c.Service.DB.Query(` + SELECT r.id, r.message_id, r.user_id, r.emoji, u.full_name + FROM message_reactions r + JOIN users u ON r.user_id = u.id + WHERE r.message_id = ? + ORDER BY r.created_at ASC`, id) + + reactions := make([]models.Reaction, 0) + for reactRows.Next() { + var reaction models.Reaction + _ = reactRows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &reaction.UserName) + reactions = append(reactions, reaction) + } + reactRows.Close() + if len(reactions) > 0 { + message.Reactions = reactions + } + + messages = append(messages, message) + } + + utils.WriteJSON(w, http.StatusOK, messages) +} + +func (c *Controller) SendMessageHandler(w http.ResponseWriter, r *http.Request) { + var message models.Message + if err := json.NewDecoder(r.Body).Decode(&message); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + if message.From == 0 { + utils.WriteAPIError(w, http.StatusBadRequest, "from is required") + return + } + if message.GroupID == 0 && message.To == 0 { + utils.WriteAPIError(w, http.StatusBadRequest, "to or groupId is required") + return + } + if strings.TrimSpace(message.Content) == "" && strings.TrimSpace(message.MediaURL) == "" { + utils.WriteAPIError(w, http.StatusBadRequest, "content or mediaUrl is required") + return + } + + message.Timestamp = time.Now().Unix() + + var result sql.Result + if message.GroupID > 0 { + result, _ = c.Service.DB.Exec( + "INSERT INTO messages (from_user, group_id, content, media_url, media_type, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?, ?)", + message.From, message.GroupID, message.Content, message.MediaURL, message.MediaType, message.Latitude, message.Longitude, + ) + + rows, _ := c.Service.DB.Query("SELECT user_id FROM group_members WHERE group_id = ?", message.GroupID) + for rows.Next() { + var memberID int + _ = rows.Scan(&memberID) + if memberID == message.From { + continue + } + data, _ := json.Marshal(message) + c.Service.SendToUser(memberID, data) + } + rows.Close() + } else { + var blockCount int + _ = c.Service.DB.QueryRow("SELECT COUNT(*) FROM blocked_users WHERE blocker_id = ? AND blocked_id = ?", message.To, message.From).Scan(&blockCount) + if blockCount > 0 { + utils.WriteAPIError(w, http.StatusForbidden, "user has blocked you") + return + } + + result, _ = c.Service.DB.Exec( + "INSERT INTO messages (from_user, to_user, content, media_url, media_type, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?, ?)", + message.From, message.To, message.Content, message.MediaURL, message.MediaType, message.Latitude, message.Longitude, + ) + + data, _ := json.Marshal(message) + c.Service.SendToUser(message.To, data) + } + + messageID, _ := result.LastInsertId() + utils.WriteJSON(w, http.StatusCreated, map[string]interface{}{"status": "ok", "messageId": messageID}) +} + +func (c *Controller) TypingHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + From int `json:"from"` + To int `json:"to"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + data, _ := json.Marshal(map[string]interface{}{"type": "typing", "from": req.From}) + c.Service.SendToUser(req.To, data) + utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (c *Controller) UploadHandler(w http.ResponseWriter, r *http.Request) { + err := r.ParseMultipartForm(50 << 20) + if err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "failed to parse form") + return + } + + file, header, err := r.FormFile("file") + if err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "file field required") + return + } + defer file.Close() + + _ = os.MkdirAll("uploads", 0755) + ext := filepath.Ext(header.Filename) + filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) + dst, err := os.Create(filepath.Join("uploads", filename)) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to save file") + return + } + defer dst.Close() + + _, _ = io.Copy(dst, file) + utils.WriteJSON(w, http.StatusCreated, map[string]string{"url": fmt.Sprintf("/uploads/%s", filename)}) +} + +func (c *Controller) SSEHandler(w http.ResponseWriter, r *http.Request) { + userID, _ := strconv.Atoi(r.URL.Query().Get("userId")) + if userID == 0 { + utils.WriteAPIError(w, http.StatusBadRequest, "userId is required") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + utils.WriteAPIError(w, http.StatusInternalServerError, "streaming not supported") + return + } + + client := c.Service.RegisterClient(userID) + defer c.Service.UnregisterClient(userID) + + _, _ = fmt.Fprintf(w, "data: {\"type\":\"connected\"}\n\n") + flusher.Flush() + + for { + select { + case msg := <-client.Messages: + _, _ = fmt.Fprintf(w, "data: %s\n\n", msg) + flusher.Flush() + case <-r.Context().Done(): + return + case <-client.Done: + return + } + } +} + +func (c *Controller) OnlineUsersHandler(w http.ResponseWriter, r *http.Request) { + utils.WriteJSON(w, http.StatusOK, c.Service.OnlineUserIDs()) +} + +func (c *Controller) BlockUserHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + BlockerID int `json:"blockerId"` + BlockedID int `json:"blockedId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + _, err := c.Service.DB.Exec("INSERT OR IGNORE INTO blocked_users (blocker_id, blocked_id) VALUES (?, ?)", req.BlockerID, req.BlockedID) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to block user") + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "blocked"}) +} + +func (c *Controller) UnblockUserHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + BlockerID int `json:"blockerId"` + BlockedID int `json:"blockedId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + _, err := c.Service.DB.Exec("DELETE FROM blocked_users WHERE blocker_id = ? AND blocked_id = ?", req.BlockerID, req.BlockedID) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to unblock user") + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "unblocked"}) +} + +func (c *Controller) GetBlockedUsersHandler(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Query().Get("userId") + rows, err := c.Service.DB.Query("SELECT blocked_id FROM blocked_users WHERE blocker_id = ?", userID) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "server error") + return + } + defer rows.Close() + + blockedIDs := make([]int, 0) + for rows.Next() { + var id int + _ = rows.Scan(&id) + blockedIDs = append(blockedIDs, id) + } + + utils.WriteJSON(w, http.StatusOK, blockedIDs) +} + +func (c *Controller) MarkMessageReadHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + MessageID int `json:"messageId"` + UserID int `json:"userId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + _, err := c.Service.DB.Exec("INSERT OR IGNORE INTO message_reads (message_id, user_id) VALUES (?, ?)", req.MessageID, req.UserID) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to mark as read") + return + } + + var fromUser sql.NullInt64 + _ = c.Service.DB.QueryRow("SELECT from_user FROM messages WHERE id = ?", req.MessageID).Scan(&fromUser) + if fromUser.Valid && int(fromUser.Int64) != req.UserID { + data, _ := json.Marshal(map[string]interface{}{"type": "read_receipt", "messageId": req.MessageID, "userId": req.UserID}) + c.Service.SendToUser(int(fromUser.Int64), data) + } + + utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (c *Controller) AddReactionHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + MessageID int `json:"messageId"` + UserID int `json:"userId"` + Emoji string `json:"emoji"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + result, err := c.Service.DB.Exec("INSERT OR IGNORE INTO message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)", req.MessageID, req.UserID, req.Emoji) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to add reaction") + return + } + + c.broadcastReaction(req.MessageID, req.UserID, "add", req.Emoji) + id, _ := result.LastInsertId() + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"id": id, "status": "ok"}) +} + +func (c *Controller) RemoveReactionHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + MessageID int `json:"messageId"` + UserID int `json:"userId"` + Emoji string `json:"emoji"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + _, err := c.Service.DB.Exec("DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?", req.MessageID, req.UserID, req.Emoji) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to remove reaction") + return + } + + c.broadcastReaction(req.MessageID, req.UserID, "remove", req.Emoji) + utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (c *Controller) GetMessageReactionsHandler(w http.ResponseWriter, r *http.Request) { + messageID := r.URL.Query().Get("messageId") + rows, err := c.Service.DB.Query(` + SELECT r.id, r.message_id, r.user_id, r.emoji, u.full_name + FROM message_reactions r + JOIN users u ON r.user_id = u.id + WHERE r.message_id = ? + ORDER BY r.created_at ASC`, messageID) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "server error") + return + } + defer rows.Close() + + reactions := make([]models.Reaction, 0) + for rows.Next() { + var reaction models.Reaction + _ = rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &reaction.UserName) + reactions = append(reactions, reaction) + } + + utils.WriteJSON(w, http.StatusOK, reactions) +} + +func (c *Controller) LeaveGroupHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + GroupID int `json:"groupId"` + UserID int `json:"userId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + _, err := c.Service.DB.Exec("DELETE FROM group_members WHERE group_id = ? AND user_id = ?", req.GroupID, req.UserID) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to leave group") + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "left"}) +} + +func (c *Controller) RemoveGroupMemberHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + GroupID int `json:"groupId"` + UserID int `json:"userId"` + RemoverID int `json:"removerId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + var creatorID int + err := c.Service.DB.QueryRow("SELECT creator_id FROM groups WHERE id = ?", req.GroupID).Scan(&creatorID) + if err != nil || creatorID != req.RemoverID { + utils.WriteAPIError(w, http.StatusForbidden, "only group creator can remove members") + return + } + + _, err = c.Service.DB.Exec("DELETE FROM group_members WHERE group_id = ? AND user_id = ?", req.GroupID, req.UserID) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to remove member") + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "removed"}) +} + +func (c *Controller) AddGroupMemberHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + GroupID int `json:"groupId"` + UserID int `json:"userId"` + AdderID int `json:"adderId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + if req.GroupID == 0 || req.UserID == 0 || req.AdderID == 0 { + utils.WriteAPIError(w, http.StatusBadRequest, "groupId, userId and adderId are required") + return + } + + var creatorID int + err := c.Service.DB.QueryRow("SELECT creator_id FROM groups WHERE id = ?", req.GroupID).Scan(&creatorID) + if err != nil { + utils.WriteAPIError(w, http.StatusNotFound, "group not found") + return + } + + if creatorID != req.AdderID { + utils.WriteAPIError(w, http.StatusForbidden, "only group creator can add members") + return + } + + _, err = c.Service.DB.Exec("INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)", req.GroupID, req.UserID) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to add member") + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "added"}) +} + +func (c *Controller) GetGroupMembersHandler(w http.ResponseWriter, r *http.Request) { + groupID := r.URL.Query().Get("groupId") + rows, err := c.Service.DB.Query(` + SELECT u.id, u.username, u.full_name + FROM users u + JOIN group_members gm ON u.id = gm.user_id + WHERE gm.group_id = ? + ORDER BY u.full_name`, groupID) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "server error") + return + } + defer rows.Close() + + members := make([]models.User, 0) + for rows.Next() { + var user models.User + _ = rows.Scan(&user.ID, &user.Username, &user.FullName) + members = append(members, user) + } + + utils.WriteJSON(w, http.StatusOK, members) +} + +func (c *Controller) SavePublicKeyHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID int `json:"userId"` + PublicKey string `json:"publicKey"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, "invalid request") + return + } + + _, err := c.Service.DB.Exec(`INSERT INTO user_keys (user_id, public_key, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET public_key = ?, updated_at = CURRENT_TIMESTAMP`, + req.UserID, req.PublicKey, req.PublicKey) + if err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, "failed to save key") + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (c *Controller) GetPublicKeyHandler(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Query().Get("userId") + var publicKey string + err := c.Service.DB.QueryRow("SELECT public_key FROM user_keys WHERE user_id = ?", userID).Scan(&publicKey) + if err != nil { + utils.WriteAPIError(w, http.StatusNotFound, "key not found") + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]string{"publicKey": publicKey}) +} + +func (c *Controller) broadcastReaction(messageID int, userID int, action string, emoji string) { + var fromUser, toUser, groupID sql.NullInt64 + _ = c.Service.DB.QueryRow("SELECT from_user, to_user, group_id FROM messages WHERE id = ?", messageID).Scan(&fromUser, &toUser, &groupID) + + data, _ := json.Marshal(map[string]interface{}{ + "type": "reaction", + "action": action, + "messageId": messageID, + "userId": userID, + "emoji": emoji, + }) + + if groupID.Valid { + rows, _ := c.Service.DB.Query("SELECT user_id FROM group_members WHERE group_id = ?", groupID.Int64) + for rows.Next() { + var memberID int + _ = rows.Scan(&memberID) + if memberID != userID { + c.Service.SendToUser(memberID, data) + } + } + rows.Close() + return + } + + if fromUser.Valid && int(fromUser.Int64) != userID { + c.Service.SendToUser(int(fromUser.Int64), data) + } + if toUser.Valid && int(toUser.Int64) != userID { + c.Service.SendToUser(int(toUser.Int64), data) + } +} diff --git a/backend/internal/middleware/http.go b/backend/internal/middleware/http.go new file mode 100644 index 0000000..95c3373 --- /dev/null +++ b/backend/internal/middleware/http.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "net/http" + + "github.com/username/tcp-chat/internal/utils" +) + +func EnableCORS(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") +} + +func MethodGuard(allowed map[string]bool, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + EnableCORS(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + if !allowed[r.Method] { + utils.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + next(w, r) + } +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..117aca1 --- /dev/null +++ b/backend/internal/models/models.go @@ -0,0 +1,54 @@ +package models + +import "github.com/golang-jwt/jwt/v5" + +type Claims struct { + UserID int `json:"userId"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +type Client struct { + ID int + Username string + Messages chan []byte + Done chan bool +} + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + FullName string `json:"fullName"` + Bio string `json:"bio,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` +} + +type Message struct { + ID int `json:"id,omitempty"` + Type string `json:"type"` + Content string `json:"content,omitempty"` + From int `json:"from,omitempty"` + To int `json:"to,omitempty"` + GroupID int `json:"groupId,omitempty"` + MediaURL string `json:"mediaUrl,omitempty"` + MediaType string `json:"mediaType,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + IsRead bool `json:"isRead,omitempty"` + Reactions []Reaction `json:"reactions,omitempty"` +} + +type Reaction struct { + ID int `json:"id"` + MessageID int `json:"messageId"` + UserID int `json:"userId"` + Emoji string `json:"emoji"` + UserName string `json:"userName,omitempty"` +} + +type Group struct { + ID int `json:"id"` + Name string `json:"name"` + Creator int `json:"creator"` +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go new file mode 100644 index 0000000..a0fbaf5 --- /dev/null +++ b/backend/internal/routes/routes.go @@ -0,0 +1,39 @@ +package routes + +import ( + "net/http" + + "github.com/username/tcp-chat/internal/controllers" + "github.com/username/tcp-chat/internal/middleware" +) + +func Register(mux *http.ServeMux, controller *controllers.Controller) { + allowGet := map[string]bool{http.MethodGet: true} + allowPost := map[string]bool{http.MethodPost: true} + allowGetPost := map[string]bool{http.MethodGet: true, http.MethodPost: true} + + mux.HandleFunc("/api/register", middleware.MethodGuard(allowPost, controller.RegisterHandler)) + mux.HandleFunc("/api/login", middleware.MethodGuard(allowPost, controller.LoginHandler)) + mux.HandleFunc("/api/users", middleware.MethodGuard(allowGet, controller.GetUsersHandler)) + mux.HandleFunc("/api/profile", middleware.MethodGuard(allowGetPost, controller.ProfileHandler)) + mux.HandleFunc("/api/groups", middleware.MethodGuard(allowGetPost, controller.GroupsHandler)) + mux.HandleFunc("/api/messages", middleware.MethodGuard(allowGet, controller.GetMessagesHandler)) + mux.HandleFunc("/api/send", middleware.MethodGuard(allowPost, controller.SendMessageHandler)) + mux.HandleFunc("/api/typing", middleware.MethodGuard(allowPost, controller.TypingHandler)) + mux.HandleFunc("/api/upload", middleware.MethodGuard(allowPost, controller.UploadHandler)) + mux.HandleFunc("/api/online", middleware.MethodGuard(allowGet, controller.OnlineUsersHandler)) + mux.HandleFunc("/api/block", middleware.MethodGuard(allowPost, controller.BlockUserHandler)) + mux.HandleFunc("/api/unblock", middleware.MethodGuard(allowPost, controller.UnblockUserHandler)) + mux.HandleFunc("/api/blocked", middleware.MethodGuard(allowGet, controller.GetBlockedUsersHandler)) + mux.HandleFunc("/api/group/leave", middleware.MethodGuard(allowPost, controller.LeaveGroupHandler)) + mux.HandleFunc("/api/group/add", middleware.MethodGuard(allowPost, controller.AddGroupMemberHandler)) + mux.HandleFunc("/api/group/remove", middleware.MethodGuard(allowPost, controller.RemoveGroupMemberHandler)) + mux.HandleFunc("/api/group/members", middleware.MethodGuard(allowGet, controller.GetGroupMembersHandler)) + mux.HandleFunc("/api/messages/read", middleware.MethodGuard(allowPost, controller.MarkMessageReadHandler)) + mux.HandleFunc("/api/reactions/add", middleware.MethodGuard(allowPost, controller.AddReactionHandler)) + mux.HandleFunc("/api/reactions/remove", middleware.MethodGuard(allowPost, controller.RemoveReactionHandler)) + mux.HandleFunc("/api/reactions", middleware.MethodGuard(allowGet, controller.GetMessageReactionsHandler)) + mux.HandleFunc("/api/keys/save", middleware.MethodGuard(allowPost, controller.SavePublicKeyHandler)) + mux.HandleFunc("/api/keys/get", middleware.MethodGuard(allowGet, controller.GetPublicKeyHandler)) + mux.HandleFunc("/events", middleware.MethodGuard(allowGet, controller.SSEHandler)) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go new file mode 100644 index 0000000..c190c9e --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,60 @@ +package server + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/username/tcp-chat/internal/config" + "github.com/username/tcp-chat/internal/controllers" + "github.com/username/tcp-chat/internal/routes" + "github.com/username/tcp-chat/internal/services" +) + +func Run(cfg config.Config) error { + logger := log.New(os.Stdout, "[parschat] ", log.LstdFlags) + + service, err := services.NewChatService(cfg.DBPath, cfg.JWTSecret, logger) + if err != nil { + return err + } + + controller := controllers.NewController(service) + mux := http.NewServeMux() + + routes.Register(mux, controller) + mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir("./uploads")))) + + frontendRoot := "./public" + frontendCandidates := []string{"./frontend/dist", "../frontend/dist", "./public"} + for _, candidate := range frontendCandidates { + if stat, statErr := os.Stat(candidate); statErr == nil && stat.IsDir() { + frontendRoot = candidate + break + } + } + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if path == "/" { + path = "/index.html" + } + + filePath := frontendRoot + path + if _, err := os.Stat(filePath); err == nil { + http.ServeFile(w, r, filePath) + return + } + + http.ServeFile(w, r, frontendRoot+"/index.html") + }) + + addr := fmt.Sprintf(":%s", cfg.Port) + logger.Println("========================================") + logger.Println("Chat Server Started!") + logger.Printf("http://localhost:%s\n", cfg.Port) + logger.Println("========================================") + + return http.ListenAndServe(addr, mux) +} diff --git a/backend/internal/services/chat_service.go b/backend/internal/services/chat_service.go new file mode 100644 index 0000000..6d52c53 --- /dev/null +++ b/backend/internal/services/chat_service.go @@ -0,0 +1,200 @@ +package services + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "log" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + _ "github.com/mattn/go-sqlite3" + "github.com/username/tcp-chat/internal/models" +) + +type ChatService struct { + DB *sql.DB + Clients map[int]*models.Client + Mu sync.RWMutex + JWTSecret []byte + Logger *log.Logger +} + +func NewChatService(dbPath string, jwtSecret string, logger *log.Logger) (*ChatService, error) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, err + } + + svc := &ChatService{ + DB: db, + Clients: make(map[int]*models.Client), + JWTSecret: []byte(jwtSecret), + Logger: logger, + } + + if err := svc.initSchema(); err != nil { + return nil, err + } + + return svc, nil +} + +func (s *ChatService) initSchema() error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + full_name TEXT NOT NULL, + password TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + creator_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS group_members ( + group_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(group_id, user_id) + )`, + `CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user INTEGER NOT NULL, + to_user INTEGER, + group_id INTEGER, + content TEXT, + media_url TEXT, + media_type TEXT, + latitude REAL, + longitude REAL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS message_reads ( + message_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + read_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(message_id, user_id) + )`, + `CREATE TABLE IF NOT EXISTS message_reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + emoji TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(message_id, user_id, emoji) + )`, + `CREATE TABLE IF NOT EXISTS blocked_users ( + blocker_id INTEGER NOT NULL, + blocked_id INTEGER NOT NULL, + blocked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(blocker_id, blocked_id) + )`, + `CREATE TABLE IF NOT EXISTS user_keys ( + user_id INTEGER PRIMARY KEY, + public_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + } + + for _, statement := range statements { + if _, err := s.DB.Exec(statement); err != nil { + return err + } + } + + _, _ = s.DB.Exec("ALTER TABLE users ADD COLUMN bio TEXT NOT NULL DEFAULT ''") + _, _ = s.DB.Exec("ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT ''") + + if s.Logger != nil { + s.Logger.Println("[OK] Database initialized") + } + + return nil +} + +func (s *ChatService) HashPassword(password string) string { + hash := sha256.Sum256([]byte(password + "chat_salt_2024")) + return hex.EncodeToString(hash[:]) +} + +func (s *ChatService) GenerateJWT(userID int, username string) (string, error) { + expirationTime := time.Now().Add(24 * time.Hour) + claims := &models.Claims{ + UserID: userID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(s.JWTSecret) +} + +func (s *ChatService) ValidateJWT(tokenString string) (*models.Claims, error) { + claims := &models.Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return s.JWTSecret, nil + }) + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return claims, nil +} + +func (s *ChatService) RegisterClient(userID int) *models.Client { + client := &models.Client{ + ID: userID, + Messages: make(chan []byte, 100), + Done: make(chan bool), + } + + s.Mu.Lock() + s.Clients[userID] = client + s.Mu.Unlock() + + return client +} + +func (s *ChatService) UnregisterClient(userID int) { + s.Mu.Lock() + delete(s.Clients, userID) + s.Mu.Unlock() +} + +func (s *ChatService) SendToUser(userID int, data []byte) { + s.Mu.RLock() + client, ok := s.Clients[userID] + s.Mu.RUnlock() + if !ok { + return + } + + select { + case client.Messages <- data: + default: + } +} + +func (s *ChatService) OnlineUserIDs() []int { + s.Mu.RLock() + ids := make([]int, 0, len(s.Clients)) + for userID := range s.Clients { + ids = append(ids, userID) + } + s.Mu.RUnlock() + return ids +} diff --git a/backend/internal/utils/response.go b/backend/internal/utils/response.go new file mode 100644 index 0000000..f51bf97 --- /dev/null +++ b/backend/internal/utils/response.go @@ -0,0 +1,16 @@ +package utils + +import ( + "encoding/json" + "net/http" +) + +func WriteJSON(w http.ResponseWriter, status int, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func WriteAPIError(w http.ResponseWriter, status int, message string) { + WriteJSON(w, status, map[string]string{"error": message}) +} diff --git a/public/app.js b/backend/public/app.js similarity index 100% rename from public/app.js rename to backend/public/app.js diff --git a/public/index.html b/backend/public/index.html similarity index 99% rename from public/index.html rename to backend/public/index.html index 5e03bc9..0fdd2d2 100644 --- a/public/index.html +++ b/backend/public/index.html @@ -684,11 +684,6 @@ background: #71828f; animation: typing 1.4s infinite; } - height: 8px; - border-radius: 50%; - background: #8696a0; - animation: typing 1.4s infinite; - } .typing-dots span:nth-child(2) { animation-delay: 0.2s; diff --git a/run.sh b/backend/run.sh similarity index 100% rename from run.sh rename to backend/run.sh diff --git a/server b/backend/server similarity index 50% rename from server rename to backend/server index 4a348f6..7bca9f0 100755 Binary files a/server and b/backend/server differ diff --git a/client b/client deleted file mode 100755 index a2700f8..0000000 Binary files a/client and /dev/null differ diff --git a/client.go b/client.go deleted file mode 100644 index 7825d77..0000000 --- a/client.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net" - "net/http" - "os" - "path/filepath" - "strings" -) - -type Message struct { - Type string `json:"type"` - First string `json:"first,omitempty"` - Last string `json:"last,omitempty"` - Text string `json:"text,omitempty"` - URL string `json:"url,omitempty"` - MediaType string `json:"mediaType,omitempty"` -} - -func uploadFile(path string) (string, error) { - file, err := os.Open(path) - if err != nil { - return "", err - } - defer file.Close() - - var b bytes.Buffer - w := multipart.NewWriter(&b) - fw, err := w.CreateFormFile("file", filepath.Base(path)) - if err != nil { - return "", err - } - if _, err := io.Copy(fw, file); err != nil { - return "", err - } - w.Close() - - resp, err := http.Post("http://localhost:8081/upload", w.FormDataContentType(), &b) - if err != nil { - return "", err - } - defer resp.Body.Close() - var data map[string]string - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return "", err - } - return data["url"], nil -} - -func detectMediaType(path string) string { - ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp": - return "image" - case ".mp4", ".mov", ".webm", ".mkv": - return "video" - default: - return "file" - } -} - -func main() { - reader := bufio.NewReader(os.Stdin) - fmt.Print("First name: ") - first, _ := reader.ReadString('\n') - fmt.Print("Last name: ") - last, _ := reader.ReadString('\n') - first = strings.TrimSpace(first) - last = strings.TrimSpace(last) - - conn, err := net.Dial("tcp", "localhost:8080") - if err != nil { - fmt.Println("Error connecting:", err) - return - } - defer conn.Close() - - // send register message - reg := Message{Type: "register", First: first, Last: last} - b, _ := json.Marshal(reg) - fmt.Fprintln(conn, string(b)) - - // listen for messages - go func() { - scanner := bufio.NewScanner(conn) - for scanner.Scan() { - var msg Message - if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { - fmt.Println(string(scanner.Bytes())) - continue - } - switch msg.Type { - case "info": - fmt.Println(msg.Text) - case "text": - fmt.Printf("[%s]: %s\n", msg.First, msg.Text) - case "media": - fmt.Printf("[%s] sent %s: %s\n", msg.First, msg.MediaType, msg.URL) - default: - fmt.Println(scanner.Text()) - } - } - }() - - input := bufio.NewScanner(os.Stdin) - for input.Scan() { - line := strings.TrimSpace(input.Text()) - if strings.HasPrefix(line, "/upload ") { - path := strings.TrimSpace(strings.TrimPrefix(line, "/upload ")) - if path == "" { - fmt.Println("usage: /upload ") - continue - } - url, err := uploadFile(path) - if err != nil { - fmt.Println("upload failed:", err) - continue - } - mediaType := detectMediaType(path) - msg := Message{Type: "media", URL: url, MediaType: mediaType} - jb, _ := json.Marshal(msg) - fmt.Fprintln(conn, string(jb)) - } else { - msg := Message{Type: "text", Text: line} - jb, _ := json.Marshal(msg) - fmt.Fprintln(conn, string(jb)) - } - } -} diff --git a/cmd/server/main.go b/cmd/server/main.go deleted file mode 100644 index 4f3b8ac..0000000 --- a/cmd/server/main.go +++ /dev/null @@ -1,1284 +0,0 @@ -package main - -import ( - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "time" - - "github.com/golang-jwt/jwt/v5" - _ "github.com/mattn/go-sqlite3" -) - -var ( - db *sql.DB - clients = make(map[int]*Client) - mu sync.RWMutex - jwtSecret = []byte("your-secret-key-change-in-production-12345") -) - -// Claims represents JWT claims -type Claims struct { - UserID int `json:"userId"` - Username string `json:"username"` - jwt.RegisteredClaims -} - -// Client represents a connected user -type Client struct { - ID int - Username string - Messages chan []byte - Done chan bool -} - -// User represents a user in the database -type User struct { - ID int `json:"id"` - Username string `json:"username"` - FullName string `json:"fullName"` -} - -// Message represents a chat message -type Message struct { - ID int `json:"id,omitempty"` - Type string `json:"type"` - Content string `json:"content,omitempty"` - From int `json:"from,omitempty"` - To int `json:"to,omitempty"` - GroupID int `json:"groupId,omitempty"` - MediaURL string `json:"mediaUrl,omitempty"` - MediaType string `json:"mediaType,omitempty"` - Latitude *float64 `json:"latitude,omitempty"` - Longitude *float64 `json:"longitude,omitempty"` - Timestamp int64 `json:"timestamp,omitempty"` - IsRead bool `json:"isRead,omitempty"` - Reactions []Reaction `json:"reactions,omitempty"` -} - -// Reaction represents emoji reaction on a message -type Reaction struct { - ID int `json:"id"` - MessageID int `json:"messageId"` - UserID int `json:"userId"` - Emoji string `json:"emoji"` - UserName string `json:"userName,omitempty"` -} - -// Group represents a chat group -type Group struct { - ID int `json:"id"` - Name string `json:"name"` - Creator int `json:"creator"` -} - -// initDB initializes the SQLite database and creates tables -func initDB() { - var err error - db, err = sql.Open("sqlite3", "./chat.db") - if err != nil { - log.Fatal(err) - } - - // Create users table - db.Exec(`CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - full_name TEXT NOT NULL, - password TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`) - - // Create groups table - db.Exec(`CREATE TABLE IF NOT EXISTS groups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - creator_id INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`) - - // Create group members table - db.Exec(`CREATE TABLE IF NOT EXISTS group_members ( - group_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY(group_id, user_id) - )`) - - // Create messages table - db.Exec(`CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_user INTEGER NOT NULL, - to_user INTEGER, - group_id INTEGER, - content TEXT, - media_url TEXT, - media_type TEXT, - latitude REAL, - longitude REAL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP - )`) - - // Create message reads table for read receipts - db.Exec(`CREATE TABLE IF NOT EXISTS message_reads ( - message_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - read_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY(message_id, user_id) - )`) - - // Create message reactions table - db.Exec(`CREATE TABLE IF NOT EXISTS message_reactions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - message_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - emoji TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(message_id, user_id, emoji) - )`) - - // Create blocked users table - db.Exec(`CREATE TABLE IF NOT EXISTS blocked_users ( - blocker_id INTEGER NOT NULL, - blocked_id INTEGER NOT NULL, - blocked_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY(blocker_id, blocked_id) - )`) - - // Create user encryption keys table for E2E encryption - db.Exec(`CREATE TABLE IF NOT EXISTS user_keys ( - user_id INTEGER PRIMARY KEY, - public_key TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`) - - log.Println("[OK] Database initialized") -} - -// hashPassword creates a SHA256 hash of the password -func hashPassword(password string) string { - hash := sha256.Sum256([]byte(password + "chat_salt_2024")) - return hex.EncodeToString(hash[:]) -} - -// generateJWT creates a new JWT token for user -func generateJWT(userID int, username string) (string, error) { - expirationTime := time.Now().Add(24 * time.Hour) - claims := &Claims{ - UserID: userID, - Username: username, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(expirationTime), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(jwtSecret) -} - -// validateJWT validates the JWT token and returns claims -func validateJWT(tokenString string) (*Claims, error) { - claims := &Claims{} - token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { - return jwtSecret, nil - }) - - if err != nil { - return nil, err - } - - if !token.Valid { - return nil, fmt.Errorf("invalid token") - } - - return claims, nil -} - -// authMiddleware validates JWT from Authorization header -func authMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - http.Error(w, "Authorization header required", http.StatusUnauthorized) - return - } - - // Extract token from "Bearer " - parts := strings.Split(authHeader, " ") - if len(parts) != 2 || parts[0] != "Bearer" { - http.Error(w, "Invalid authorization header format", http.StatusUnauthorized) - return - } - - _, err := validateJWT(parts[1]) - if err != nil { - http.Error(w, "Invalid or expired token", http.StatusUnauthorized) - return - } - - next(w, r) - } -} - -// enableCORS sets CORS headers for cross-origin requests -func enableCORS(w http.ResponseWriter) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") -} - -// registerHandler handles user registration -func registerHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - Username string `json:"username"` - FullName string `json:"fullName"` - Password string `json:"password"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - hash := hashPassword(req.Password) - - result, err := db.Exec("INSERT INTO users (username, full_name, password) VALUES (?, ?, ?)", - req.Username, req.FullName, hash) - if err != nil { - http.Error(w, "Username already exists", http.StatusConflict) - return - } - - id, _ := result.LastInsertId() - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": id, - "username": req.Username, - "fullName": req.FullName, - }) -} - -// loginHandler handles user authentication -func loginHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - Username string `json:"username"` - Password string `json:"password"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - var user User - var storedHash string - err := db.QueryRow("SELECT id, username, full_name, password FROM users WHERE username = ?", - req.Username).Scan(&user.ID, &user.Username, &user.FullName, &storedHash) - - if err != nil || hashPassword(req.Password) != storedHash { - http.Error(w, "Invalid credentials", http.StatusUnauthorized) - return - } - - // Generate JWT token - token, err := generateJWT(user.ID, user.Username) - if err != nil { - http.Error(w, "Failed to generate token", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": user.ID, - "username": user.Username, - "fullName": user.FullName, - "token": token, - }) -} - -// getUsersHandler returns list of all users -func getUsersHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - rows, err := db.Query("SELECT id, username, full_name FROM users ORDER BY username") - if err != nil { - http.Error(w, "Server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var users []User - for rows.Next() { - var u User - rows.Scan(&u.ID, &u.Username, &u.FullName) - users = append(users, u) - } - - if users == nil { - users = []User{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(users) -} - -// createGroupHandler creates a new chat group -func createGroupHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - Name string `json:"name"` - CreatorID int `json:"creatorId"` - MemberIDs []int `json:"memberIds"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - tx, _ := db.Begin() - result, err := tx.Exec("INSERT INTO groups (name, creator_id) VALUES (?, ?)", - req.Name, req.CreatorID) - if err != nil { - tx.Rollback() - http.Error(w, "Failed to create group", http.StatusInternalServerError) - return - } - - groupID, _ := result.LastInsertId() - - // Add creator as member - tx.Exec("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", - groupID, req.CreatorID) - - // Add other members - for _, memberID := range req.MemberIDs { - tx.Exec("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", - groupID, memberID) - } - - tx.Commit() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": groupID, - "name": req.Name, - }) -} - -// getGroupsHandler returns groups for a specific user -func getGroupsHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - userID := r.URL.Query().Get("userId") - rows, err := db.Query(` - SELECT DISTINCT g.id, g.name, g.creator_id - FROM groups g - JOIN group_members gm ON g.id = gm.group_id - WHERE gm.user_id = ? - ORDER BY g.name`, userID) - if err != nil { - http.Error(w, "Server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var groups []Group - for rows.Next() { - var g Group - rows.Scan(&g.ID, &g.Name, &g.Creator) - groups = append(groups, g) - } - - if groups == nil { - groups = []Group{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(groups) -} - -// getMessagesHandler returns messages for private or group chat -func getMessagesHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - userID, _ := strconv.Atoi(r.URL.Query().Get("userId")) - contactID, _ := strconv.Atoi(r.URL.Query().Get("contactId")) - groupID, _ := strconv.Atoi(r.URL.Query().Get("groupId")) - - var rows *sql.Rows - var err error - - if groupID > 0 { - // Get group messages - rows, err = db.Query(` - SELECT id, from_user, to_user, group_id, content, media_url, media_type, - latitude, longitude, strftime('%s', timestamp) as ts - FROM messages - WHERE group_id = ? - ORDER BY timestamp ASC - LIMIT 100`, groupID) - } else { - // Get private messages between two users - rows, err = db.Query(` - SELECT id, from_user, to_user, group_id, content, media_url, media_type, - latitude, longitude, strftime('%s', timestamp) as ts - FROM messages - WHERE (from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?) - ORDER BY timestamp ASC - LIMIT 100`, userID, contactID, contactID, userID) - } - - if err != nil { - http.Error(w, "Server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var messages []Message - for rows.Next() { - var m Message - var id int - var toUser, groupIDVal sql.NullInt64 - var content, mediaURL, mediaType sql.NullString - var latitude, longitude sql.NullFloat64 - rows.Scan(&id, &m.From, &toUser, &groupIDVal, &content, &mediaURL, &mediaType, &latitude, &longitude, &m.Timestamp) - m.ID = id - if toUser.Valid { - m.To = int(toUser.Int64) - } - if groupIDVal.Valid { - m.GroupID = int(groupIDVal.Int64) - } - if content.Valid { - m.Content = content.String - } - if mediaURL.Valid { - m.MediaURL = mediaURL.String - } - if mediaType.Valid { - m.MediaType = mediaType.String - } - if latitude.Valid { - lat := latitude.Float64 - m.Latitude = &lat - } - if longitude.Valid { - lon := longitude.Float64 - m.Longitude = &lon - } - m.Type = "message" - - // Load reactions for this message - reactRows, _ := db.Query(` - SELECT r.id, r.message_id, r.user_id, r.emoji, u.full_name - FROM message_reactions r - JOIN users u ON r.user_id = u.id - WHERE r.message_id = ? - ORDER BY r.created_at ASC`, id) - - var reactions []Reaction - for reactRows.Next() { - var r Reaction - reactRows.Scan(&r.ID, &r.MessageID, &r.UserID, &r.Emoji, &r.UserName) - reactions = append(reactions, r) - } - reactRows.Close() - - if reactions != nil { - m.Reactions = reactions - } - - messages = append(messages, m) - } - - if messages == nil { - messages = []Message{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(messages) -} - -// uploadHandler handles file uploads -func uploadHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - err := r.ParseMultipartForm(50 << 20) // 50MB max - if err != nil { - http.Error(w, "Failed to parse form", http.StatusBadRequest) - return - } - - file, header, err := r.FormFile("file") - if err != nil { - http.Error(w, "File field required", http.StatusBadRequest) - return - } - defer file.Close() - - // Create uploads directory if not exists - os.MkdirAll("uploads", 0755) - - // Generate unique filename - ext := filepath.Ext(header.Filename) - filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) - dst, err := os.Create(filepath.Join("uploads", filename)) - if err != nil { - http.Error(w, "Failed to save file", http.StatusInternalServerError) - return - } - defer dst.Close() - - io.Copy(dst, file) - - url := fmt.Sprintf("/uploads/%s", filename) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"url": url}) -} - -// sseHandler handles Server-Sent Events for real-time messaging -func sseHandler(w http.ResponseWriter, r *http.Request) { - userID, _ := strconv.Atoi(r.URL.Query().Get("userId")) - if userID == 0 { - http.Error(w, "Invalid user ID", http.StatusBadRequest) - return - } - - // Set SSE headers - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "Streaming not supported", http.StatusInternalServerError) - return - } - - // Create client - client := &Client{ - ID: userID, - Messages: make(chan []byte, 100), - Done: make(chan bool), - } - - // Register client - mu.Lock() - clients[userID] = client - mu.Unlock() - - // Cleanup on disconnect - defer func() { - mu.Lock() - delete(clients, userID) - mu.Unlock() - }() - - // Send initial connection message - fmt.Fprintf(w, "data: {\"type\":\"connected\"}\n\n") - flusher.Flush() - - // Listen for messages - for { - select { - case msg := <-client.Messages: - fmt.Fprintf(w, "data: %s\n\n", msg) - flusher.Flush() - case <-r.Context().Done(): - return - case <-client.Done: - return - } - } -} - -// sendMessageHandler handles sending messages -func sendMessageHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var msg Message - if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - msg.Timestamp = time.Now().Unix() - - // Save message to database - var result sql.Result - if msg.GroupID > 0 { - // Group message - result, _ = db.Exec("INSERT INTO messages (from_user, group_id, content, media_url, media_type, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?, ?)", - msg.From, msg.GroupID, msg.Content, msg.MediaURL, msg.MediaType, msg.Latitude, msg.Longitude) - - // Send to all group members - rows, _ := db.Query("SELECT user_id FROM group_members WHERE group_id = ?", msg.GroupID) - for rows.Next() { - var memberID int - rows.Scan(&memberID) - if memberID != msg.From { - mu.RLock() - if client, ok := clients[memberID]; ok { - data, _ := json.Marshal(msg) - select { - case client.Messages <- data: - default: - } - } - mu.RUnlock() - } - } - rows.Close() - } else { - // Check if sender is blocked by recipient - var blockCount int - db.QueryRow("SELECT COUNT(*) FROM blocked_users WHERE blocker_id = ? AND blocked_id = ?", - msg.To, msg.From).Scan(&blockCount) - - if blockCount > 0 { - // User is blocked, don't send message - http.Error(w, "User has blocked you", http.StatusForbidden) - return - } - - // Private message - result, _ = db.Exec("INSERT INTO messages (from_user, to_user, content, media_url, media_type, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?, ?)", - msg.From, msg.To, msg.Content, msg.MediaURL, msg.MediaType, msg.Latitude, msg.Longitude) - - // Send to recipient - mu.RLock() - if client, ok := clients[msg.To]; ok { - data, _ := json.Marshal(msg) - select { - case client.Messages <- data: - default: - } - } - mu.RUnlock() - } - - // Get the message ID - messageID, _ := result.LastInsertId() - msg.ID = int(messageID) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "ok", - "messageId": messageID, - }) -} - -// typingHandler handles typing indicator notifications -func typingHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var msg struct { - From int `json:"from"` - To int `json:"to"` - } - - if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - // Forward typing indicator to recipient - mu.RLock() - if client, ok := clients[msg.To]; ok { - data, _ := json.Marshal(map[string]interface{}{ - "type": "typing", - "from": msg.From, - }) - select { - case client.Messages <- data: - default: - } - } - mu.RUnlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -// onlineUsersHandler returns list of online user IDs -func onlineUsersHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - mu.RLock() - onlineIDs := make([]int, 0, len(clients)) - for userID := range clients { - onlineIDs = append(onlineIDs, userID) - } - mu.RUnlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(onlineIDs) -} - -// blockUserHandler handles blocking a user -func blockUserHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - BlockerID int `json:"blockerId"` - BlockedID int `json:"blockedId"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - _, err := db.Exec("INSERT OR IGNORE INTO blocked_users (blocker_id, blocked_id) VALUES (?, ?)", - req.BlockerID, req.BlockedID) - if err != nil { - http.Error(w, "Failed to block user", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "blocked"}) -} - -// unblockUserHandler handles unblocking a user -func unblockUserHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - BlockerID int `json:"blockerId"` - BlockedID int `json:"blockedId"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - _, err := db.Exec("DELETE FROM blocked_users WHERE blocker_id = ? AND blocked_id = ?", - req.BlockerID, req.BlockedID) - if err != nil { - http.Error(w, "Failed to unblock user", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "unblocked"}) -} - -// getBlockedUsersHandler returns list of blocked users for a user -func getBlockedUsersHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - userID := r.URL.Query().Get("userId") - - rows, err := db.Query("SELECT blocked_id FROM blocked_users WHERE blocker_id = ?", userID) - if err != nil { - http.Error(w, "Server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var blockedIDs []int - for rows.Next() { - var id int - rows.Scan(&id) - blockedIDs = append(blockedIDs, id) - } - - if blockedIDs == nil { - blockedIDs = []int{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(blockedIDs) -} - -// markMessageReadHandler marks a message as read -func markMessageReadHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - MessageID int `json:"messageId"` - UserID int `json:"userId"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - _, err := db.Exec("INSERT OR IGNORE INTO message_reads (message_id, user_id) VALUES (?, ?)", - req.MessageID, req.UserID) - if err != nil { - http.Error(w, "Failed to mark as read", http.StatusInternalServerError) - return - } - - // Get message details to notify sender - var fromUser, toUser sql.NullInt64 - db.QueryRow("SELECT from_user, to_user FROM messages WHERE id = ?", req.MessageID). - Scan(&fromUser, &toUser) - - // Send read receipt notification to message sender - if fromUser.Valid && int(fromUser.Int64) != req.UserID { - readNotification := map[string]interface{}{ - "type": "read_receipt", - "messageId": req.MessageID, - "userId": req.UserID, - } - data, _ := json.Marshal(readNotification) - - mu.RLock() - if client, ok := clients[int(fromUser.Int64)]; ok { - select { - case client.Messages <- data: - default: - } - } - mu.RUnlock() - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -// addReactionHandler adds emoji reaction to a message -func addReactionHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - MessageID int `json:"messageId"` - UserID int `json:"userId"` - Emoji string `json:"emoji"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - result, err := db.Exec("INSERT OR IGNORE INTO message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)", - req.MessageID, req.UserID, req.Emoji) - if err != nil { - http.Error(w, "Failed to add reaction", http.StatusInternalServerError) - return - } - - // Get message details to find recipients - var fromUser, toUser sql.NullInt64 - var groupID sql.NullInt64 - db.QueryRow("SELECT from_user, to_user, group_id FROM messages WHERE id = ?", req.MessageID). - Scan(&fromUser, &toUser, &groupID) - - // Broadcast reaction update to relevant users - reactionUpdate := map[string]interface{}{ - "type": "reaction", - "action": "add", - "messageId": req.MessageID, - "userId": req.UserID, - "emoji": req.Emoji, - } - data, _ := json.Marshal(reactionUpdate) - - mu.RLock() - if groupID.Valid { - // Send to all group members - rows, _ := db.Query("SELECT user_id FROM group_members WHERE group_id = ?", groupID.Int64) - for rows.Next() { - var memberID int - rows.Scan(&memberID) - if memberID != req.UserID { - if client, ok := clients[memberID]; ok { - select { - case client.Messages <- data: - default: - } - } - } - } - rows.Close() - } else if toUser.Valid && fromUser.Valid { - // Send to both sender and receiver in private chat - for _, recipientID := range []int{int(fromUser.Int64), int(toUser.Int64)} { - if recipientID != req.UserID { - if client, ok := clients[recipientID]; ok { - select { - case client.Messages <- data: - default: - } - } - } - } - } - mu.RUnlock() - - id, _ := result.LastInsertId() - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": id, - "status": "ok", - }) -} - -// removeReactionHandler removes emoji reaction from a message -func removeReactionHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - MessageID int `json:"messageId"` - UserID int `json:"userId"` - Emoji string `json:"emoji"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - _, err := db.Exec("DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?", - req.MessageID, req.UserID, req.Emoji) - if err != nil { - http.Error(w, "Failed to remove reaction", http.StatusInternalServerError) - return - } - - // Get message details to find recipients - var fromUser, toUser sql.NullInt64 - var groupID sql.NullInt64 - db.QueryRow("SELECT from_user, to_user, group_id FROM messages WHERE id = ?", req.MessageID). - Scan(&fromUser, &toUser, &groupID) - - // Broadcast reaction update to relevant users - reactionUpdate := map[string]interface{}{ - "type": "reaction", - "action": "remove", - "messageId": req.MessageID, - "userId": req.UserID, - "emoji": req.Emoji, - } - data, _ := json.Marshal(reactionUpdate) - - mu.RLock() - if groupID.Valid { - // Send to all group members - rows, _ := db.Query("SELECT user_id FROM group_members WHERE group_id = ?", groupID.Int64) - for rows.Next() { - var memberID int - rows.Scan(&memberID) - if memberID != req.UserID { - if client, ok := clients[memberID]; ok { - select { - case client.Messages <- data: - default: - } - } - } - } - rows.Close() - } else if toUser.Valid && fromUser.Valid { - // Send to both sender and receiver in private chat - for _, recipientID := range []int{int(fromUser.Int64), int(toUser.Int64)} { - if recipientID != req.UserID { - if client, ok := clients[recipientID]; ok { - select { - case client.Messages <- data: - default: - } - } - } - } - } - mu.RUnlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -// getMessageReactionsHandler gets all reactions for a message -func getMessageReactionsHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - messageID := r.URL.Query().Get("messageId") - - rows, err := db.Query(` - SELECT r.id, r.message_id, r.user_id, r.emoji, u.full_name - FROM message_reactions r - JOIN users u ON r.user_id = u.id - WHERE r.message_id = ? - ORDER BY r.created_at ASC`, messageID) - if err != nil { - http.Error(w, "Server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var reactions []Reaction - for rows.Next() { - var r Reaction - rows.Scan(&r.ID, &r.MessageID, &r.UserID, &r.Emoji, &r.UserName) - reactions = append(reactions, r) - } - - if reactions == nil { - reactions = []Reaction{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(reactions) -} - -// leaveGroupHandler handles user leaving a group -func leaveGroupHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - GroupID int `json:"groupId"` - UserID int `json:"userId"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - _, err := db.Exec("DELETE FROM group_members WHERE group_id = ? AND user_id = ?", - req.GroupID, req.UserID) - if err != nil { - http.Error(w, "Failed to leave group", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "left"}) -} - -// removeGroupMemberHandler handles removing a member from group -func removeGroupMemberHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - GroupID int `json:"groupId"` - UserID int `json:"userId"` - RemoverID int `json:"removerId"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - // Check if remover is the group creator - var creatorID int - err := db.QueryRow("SELECT creator_id FROM groups WHERE id = ?", req.GroupID).Scan(&creatorID) - if err != nil || creatorID != req.RemoverID { - http.Error(w, "Only group creator can remove members", http.StatusForbidden) - return - } - - _, err = db.Exec("DELETE FROM group_members WHERE group_id = ? AND user_id = ?", - req.GroupID, req.UserID) - if err != nil { - http.Error(w, "Failed to remove member", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "removed"}) -} - -// getGroupMembersHandler returns members of a group -func getGroupMembersHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - groupID := r.URL.Query().Get("groupId") - - rows, err := db.Query(` - SELECT u.id, u.username, u.full_name - FROM users u - JOIN group_members gm ON u.id = gm.user_id - WHERE gm.group_id = ? - ORDER BY u.full_name`, groupID) - if err != nil { - http.Error(w, "Server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var members []User - for rows.Next() { - var u User - rows.Scan(&u.ID, &u.Username, &u.FullName) - members = append(members, u) - } - - if members == nil { - members = []User{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(members) -} - -// savePublicKeyHandler saves user's public key for E2E encryption -func savePublicKeyHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - if r.Method == "OPTIONS" { - return - } - - var req struct { - UserID int `json:"userId"` - PublicKey string `json:"publicKey"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - _, err := db.Exec(`INSERT INTO user_keys (user_id, public_key, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(user_id) DO UPDATE SET public_key = ?, updated_at = CURRENT_TIMESTAMP`, - req.UserID, req.PublicKey, req.PublicKey) - if err != nil { - http.Error(w, "Failed to save key", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -// getPublicKeyHandler retrieves user's public key -func getPublicKeyHandler(w http.ResponseWriter, r *http.Request) { - enableCORS(w) - userID := r.URL.Query().Get("userId") - - var publicKey string - err := db.QueryRow("SELECT public_key FROM user_keys WHERE user_id = ?", userID).Scan(&publicKey) - if err != nil { - http.Error(w, "Key not found", http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"publicKey": publicKey}) -} - -func main() { - initDB() - - // API routes - http.HandleFunc("/api/register", registerHandler) - http.HandleFunc("/api/login", loginHandler) - http.HandleFunc("/api/users", getUsersHandler) - http.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - createGroupHandler(w, r) - } else { - getGroupsHandler(w, r) - } - }) - http.HandleFunc("/api/messages", getMessagesHandler) - http.HandleFunc("/api/send", sendMessageHandler) - http.HandleFunc("/api/typing", typingHandler) - http.HandleFunc("/api/upload", uploadHandler) - http.HandleFunc("/api/online", onlineUsersHandler) - http.HandleFunc("/api/block", blockUserHandler) - http.HandleFunc("/api/unblock", unblockUserHandler) - http.HandleFunc("/api/blocked", getBlockedUsersHandler) - http.HandleFunc("/api/group/leave", leaveGroupHandler) - http.HandleFunc("/api/group/remove", removeGroupMemberHandler) - http.HandleFunc("/api/group/members", getGroupMembersHandler) - http.HandleFunc("/api/messages/read", markMessageReadHandler) - http.HandleFunc("/api/reactions/add", addReactionHandler) - http.HandleFunc("/api/reactions/remove", removeReactionHandler) - http.HandleFunc("/api/reactions", getMessageReactionsHandler) - http.HandleFunc("/api/keys/save", savePublicKeyHandler) - http.HandleFunc("/api/keys/get", getPublicKeyHandler) - http.HandleFunc("/events", sseHandler) - - // Serve uploaded files - http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir("./uploads")))) - - // Serve frontend files - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - if path == "/" { - path = "/index.html" - } - - filePath := "./public" + path - if _, err := os.Stat(filePath); err == nil { - http.ServeFile(w, r, filePath) - } else { - http.ServeFile(w, r, "./public/index.html") - } - }) - - fmt.Println("========================================") - fmt.Println("Chat Server Started!") - fmt.Println("http://localhost:8080") - fmt.Println("========================================") - log.Fatal(http.ListenAndServe(":8080", nil)) -} diff --git a/docker-compose.yml b/docker-compose.yml index e039570..101f0e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,29 @@ -version: "3.9" - services: - chat-server: - build: . - container_name: chat-server + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: parschat-backend ports: - "8080:8080" + environment: + - PORT=8080 + - DB_PATH=/app/chat.db + - JWT_SECRET=change-this-in-production volumes: - - ./uploads:/app/uploads - - ./chat.db:/app/chat.db + - ./backend/uploads:/app/uploads + - ./backend/chat.db:/app/chat.db restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: parschat-frontend + ports: + - "5173:5173" environment: - - PORT=8080 - command: ./chat-server + - VITE_PROXY_TARGET=http://backend:8080 + depends_on: + - backend + restart: unless-stopped diff --git a/earch by username functionality in new chat modal b/earch by username functionality in new chat modal deleted file mode 100644 index 361f8c6..0000000 --- a/earch by username functionality in new chat modal +++ /dev/null @@ -1,5 +0,0 @@ -c135972 (HEAD -> main, origin/main, origin/HEAD) Merge pull request #2 from parvvaresh/feature/chat-app -6839208 (origin/feature/chat-app, feature/chat-app) feat: Add user blocking, group management, message filters, and voice messaging -aba2dee Merge pull request #1 from parvvaresh/feature/chat-app -fb1e851 feat: WhatsApp-like chat app with SSE, auth, groups, media upload -f615db3 update readme diff --git a/er A adds user B, B automatically gets A in contacts b/er A adds user B, B automatically gets A in contacts deleted file mode 100644 index 333a0b5..0000000 --- a/er A adds user B, B automatically gets A in contacts +++ /dev/null @@ -1,258 +0,0 @@ - - SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS - - Commands marked with * may be preceded by a number, _N. - Notes in parentheses indicate the behavior if _N is given. - A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K. - - h H Display this help. - q :q Q :Q ZZ Exit. - --------------------------------------------------------------------------- - - MMOOVVIINNGG - - e ^E j ^N CR * Forward one line (or _N lines). - y ^Y k ^K ^P * Backward one line (or _N lines). - f ^F ^V SPACE * Forward one window (or _N lines). - b ^B ESC-v * Backward one window (or _N lines). - z * Forward one window (and set window to _N). - w * Backward one window (and set window to _N). - ESC-SPACE * Forward one window, but don't stop at end-of-file. - d ^D * Forward one half-window (and set half-window to _N). - u ^U * Backward one half-window (and set half-window to _N). - ESC-) RightArrow * Right one half screen width (or _N positions). - ESC-( LeftArrow * Left one half screen width (or _N positions). - ESC-} ^RightArrow Right to last column displayed. - ESC-{ ^LeftArrow Left to first column. - F Forward forever; like "tail -f". - ESC-F Like F but stop when search pattern is found. - r ^R ^L Repaint screen. - R Repaint screen, discarding buffered input. - --------------------------------------------------- - Default "window" is the screen height. - Default "half-window" is half of the screen height. - --------------------------------------------------------------------------- - - SSEEAARRCCHHIINNGG - - /_p_a_t_t_e_r_n * Search forward for (_N-th) matching line. - ?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line. - n * Repeat previous search (for _N-th occurrence). - N * Repeat previous search in reverse direction. - ESC-n * Repeat previous search, spanning files. - ESC-N * Repeat previous search, reverse dir. & spanning files. - ESC-u Undo (toggle) search highlighting. - ESC-U Clear search highlighting. - &_p_a_t_t_e_r_n * Display only matching lines. - --------------------------------------------------- - A search pattern may begin with one or more of: - ^N or ! Search for NON-matching lines. - ^E or * Search multiple files (pass thru END OF FILE). - ^F or @ Start search at FIRST file (for /) or last file (for ?). - ^K Highlight matches, but don't move (KEEP position). - ^R Don't use REGULAR EXPRESSIONS. - ^W WRAP search if no match found. - --------------------------------------------------------------------------- - - JJUUMMPPIINNGG - - g < ESC-< * Go to first line in file (or line _N). - G > ESC-> * Go to last line in file (or line _N). - p % * Go to beginning of file (or _N percent into file). - t * Go to the (_N-th) next tag. - T * Go to the (_N-th) previous tag. - { ( [ * Find close bracket } ) ]. - } ) ] * Find open bracket { ( [. - ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>. - ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>. - --------------------------------------------------- - Each "find close bracket" command goes forward to the close bracket - matching the (_N-th) open bracket in the top line. - Each "find open bracket" command goes backward to the open bracket - matching the (_N-th) close bracket in the bottom line. - - m_<_l_e_t_t_e_r_> Mark the current top line with . - M_<_l_e_t_t_e_r_> Mark the current bottom line with . - '_<_l_e_t_t_e_r_> Go to a previously marked position. - '' Go to the previous position. - ^X^X Same as '. - ESC-M_<_l_e_t_t_e_r_> Clear a mark. - --------------------------------------------------- - A mark is any upper-case or lower-case letter. - Certain marks are predefined: - ^ means beginning of the file - $ means end of the file - --------------------------------------------------------------------------- - - CCHHAANNGGIINNGG FFIILLEESS - - :e [_f_i_l_e] Examine a new file. - ^X^V Same as :e. - :n * Examine the (_N-th) next file from the command line. - :p * Examine the (_N-th) previous file from the command line. - :x * Examine the first (or _N-th) file from the command line. - :d Delete the current file from the command line list. - = ^G :f Print current file name. - --------------------------------------------------------------------------- - - MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS - - -_<_f_l_a_g_> Toggle a command line option [see OPTIONS below]. - --_<_n_a_m_e_> Toggle a command line option, by name. - __<_f_l_a_g_> Display the setting of a command line option. - ___<_n_a_m_e_> Display the setting of an option, by name. - +_c_m_d Execute the less cmd each time a new file is examined. - - !_c_o_m_m_a_n_d Execute the shell command with $SHELL. - |XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command. - s _f_i_l_e Save input to a file. - v Edit the current file with $VISUAL or $EDITOR. - V Print version number of "less". - --------------------------------------------------------------------------- - - OOPPTTIIOONNSS - - Most options may be changed either on the command line, - or from within less by using the - or -- command. - Options may be given in one of two forms: either a single - character preceded by a -, or a name preceded by --. - - -? ........ --help - Display help (from command line). - -a ........ --search-skip-screen - Search skips current screen. - -A ........ --SEARCH-SKIP-SCREEN - Search starts just after target line. - -b [_N] .... --buffers=[_N] - Number of buffers. - -B ........ --auto-buffers - Don't automatically allocate buffers for pipes. - -c ........ --clear-screen - Repaint by clearing rather than scrolling. - -d ........ --dumb - Dumb terminal. - -D xx_c_o_l_o_r . --color=xx_c_o_l_o_r - Set screen colors. - -e -E .... --quit-at-eof --QUIT-AT-EOF - Quit at end of file. - -f ........ --force - Force open non-regular files. - -F ........ --quit-if-one-screen - Quit if entire file fits on first screen. - -g ........ --hilite-search - Highlight only last match for searches. - -G ........ --HILITE-SEARCH - Don't highlight any matches for searches. - -h [_N] .... --max-back-scroll=[_N] - Backward scroll limit. - -i ........ --ignore-case - Ignore case in searches that do not contain uppercase. - -I ........ --IGNORE-CASE - Ignore case in all searches. - -j [_N] .... --jump-target=[_N] - Screen position of target lines. - -J ........ --status-column - Display a status column at left edge of screen. - -k [_f_i_l_e] . --lesskey-file=[_f_i_l_e] - Use a lesskey file. - -K ........ --quit-on-intr - Exit less in response to ctrl-C. - -L ........ --no-lessopen - Ignore the LESSOPEN environment variable. - -m -M .... --long-prompt --LONG-PROMPT - Set prompt style. - -n -N .... --line-numbers --LINE-NUMBERS - Don't use line numbers. - -o [_f_i_l_e] . --log-file=[_f_i_l_e] - Copy to log file (standard input only). - -O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e] - Copy to log file (unconditionally overwrite). - -p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n] - Start at pattern (from command line). - -P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t] - Define new prompt. - -q -Q .... --quiet --QUIET --silent --SILENT - Quiet the terminal bell. - -r -R .... --raw-control-chars --RAW-CONTROL-CHARS - Output "raw" control characters. - -s ........ --squeeze-blank-lines - Squeeze multiple blank lines. - -S ........ --chop-long-lines - Chop (truncate) long lines rather than wrapping. - -t [_t_a_g] .. --tag=[_t_a_g] - Find a tag. - -T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e] - Use an alternate tags file. - -u -U .... --underline-special --UNDERLINE-SPECIAL - Change handling of backspaces. - -V ........ --version - Display the version number of "less". - -w ........ --hilite-unread - Highlight first new line after forward-screen. - -W ........ --HILITE-UNREAD - Highlight first new line after any forward movement. - -x [_N[,...]] --tabs=[_N[,...]] - Set tab stops. - -X ........ --no-init - Don't use termcap init/deinit strings. - -y [_N] .... --max-forw-scroll=[_N] - Forward scroll limit. - -z [_N] .... --window=[_N] - Set size of window. - -" [_c[_c]] . --quotes=[_c[_c]] - Set shell quote characters. - -~ ........ --tilde - Don't display tildes after end of file. - -# [_N] .... --shift=[_N] - Set horizontal scroll amount (0 = one half screen width). - --file-size - Automatically determine the size of the input file. - --follow-name - The F command changes files if the input file is renamed. - --incsearch - Search file as each pattern character is typed in. - --line-num-width=N - Set the width of the -N line number field to N characters. - --mouse - Enable mouse input. - --no-keypad - Don't send termcap keypad init/deinit strings. - --no-histdups - Remove duplicates from command history. - --rscroll=C - Set the character used to mark truncated lines. - --save-marks - Retain marks across invocations of less. - --status-col-width=N - Set the width of the -J status column to N characters. - --use-backslash - Subsequent options use backslash as escape char. - --use-color - Enables colored text. - --wheel-lines=N - Each click of the mouse wheel moves N lines. - - - --------------------------------------------------------------------------- - - LLIINNEE EEDDIITTIINNGG - - These keys can be used to edit text being entered - on the "command line" at the bottom of the screen. - - RightArrow ..................... ESC-l ... Move cursor right one character. - LeftArrow ...................... ESC-h ... Move cursor left one character. - ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word. - ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word. - HOME ........................... ESC-0 ... Move cursor to start of line. - END ............................ ESC-$ ... Move cursor to end of line. - BACKSPACE ................................ Delete char to left of cursor. - DELETE ......................... ESC-x ... Delete char under cursor. - ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor. - ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor. - ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line. - UpArrow ........................ ESC-k ... Retrieve previous command line. - DownArrow ...................... ESC-j ... Retrieve next command line. - TAB ...................................... Complete filename & cycle. - SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle. - ctrl-L ................................... Complete filename, list all. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..cb62350 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] \ No newline at end of file diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml new file mode 100644 index 0000000..c8cf41a --- /dev/null +++ b/frontend/docker-compose.yml @@ -0,0 +1,13 @@ +services: + frontend: + build: + context: . + dockerfile: Dockerfile + container_name: parschat-frontend + ports: + - "5173:5173" + environment: + - VITE_PROXY_TARGET=http://host.docker.internal:8080 + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..cfa75b7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + ParsChat + + +
+ + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..717cabe --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2050 @@ +{ + "name": "parschat-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "parschat-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.11.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.7.0", + "typescript": "^5.9.2", + "vite": "^5.4.19" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..95fd66a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "parschat-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.11.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.7.0", + "typescript": "^5.9.2", + "vite": "^5.4.19" + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..a18b4d8 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,20 @@ +import { Suspense, lazy, useEffect } from "react"; +import { LoadingState } from "./components/LoadingState"; +import { useAuthStore } from "./store"; + +const AuthView = lazy(() => import("./features/auth/AuthView").then((module) => ({ default: module.AuthView }))); +const ChatView = lazy(() => import("./features/chat/ChatView").then((module) => ({ default: module.ChatView }))); + +export function App() { + const { user, hydrate } = useAuthStore(); + + useEffect(() => { + hydrate(); + }, [hydrate]); + + return ( + }> + {user ? : } + + ); +} \ No newline at end of file diff --git a/frontend/src/components/LoadingState.tsx b/frontend/src/components/LoadingState.tsx new file mode 100644 index 0000000..ae1871d --- /dev/null +++ b/frontend/src/components/LoadingState.tsx @@ -0,0 +1,7 @@ +type LoadingStateProps = { + label?: string; +}; + +export function LoadingState({ label = "Loading..." }: LoadingStateProps) { + return

{label}

; +} diff --git a/frontend/src/features/auth/AuthView.tsx b/frontend/src/features/auth/AuthView.tsx new file mode 100644 index 0000000..379c054 --- /dev/null +++ b/frontend/src/features/auth/AuthView.tsx @@ -0,0 +1,73 @@ +import { FormEvent, useState } from "react"; +import { useAuthStore } from "../../store"; + +export function AuthView() { + const [mode, setMode] = useState<"login" | "register">("login"); + const [username, setUsername] = useState(""); + const [fullName, setFullName] = useState(""); + const [password, setPassword] = useState(""); + + const { login, register, loading, error } = useAuthStore(); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (mode === "login") { + await login(username, password); + return; + } + + const ok = await register(username, fullName, password); + if (ok) { + setMode("login"); + setPassword(""); + } + }; + + return ( +
+
+

ParsChat

+

Production-ready chat client

+ + + + {mode === "register" ? ( + + ) : null} + + + + {error ?

{error}

: null} + + + +

+ {mode === "login" ? "New user?" : "Already registered?"}{" "} + +

+
+
+ ); +} diff --git a/frontend/src/features/chat/ChatView.tsx b/frontend/src/features/chat/ChatView.tsx new file mode 100644 index 0000000..de9f0bc --- /dev/null +++ b/frontend/src/features/chat/ChatView.tsx @@ -0,0 +1,577 @@ +import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LoadingState } from "../../components/LoadingState"; +import { useEventStream } from "../../hooks/useEventStream"; +import { useAuthStore, useChatStore } from "../../store"; +import { api } from "../../services/api"; +import type { ChatTarget, RealtimeEvent, User } from "../../types"; + +function decodeUserIdFromToken(token?: string): number | null { + if (!token) return null; + try { + const payloadPart = token.split(".")[1]; + if (!payloadPart) return null; + const payload = JSON.parse(atob(payloadPart)); + const userId = Number(payload.userId); + return Number.isFinite(userId) ? userId : null; + } catch { + return null; + } +} + +export function ChatView() { + const { user, logout, updateProfile } = useAuthStore(); + const { + users, + groups, + onlineUserIds, + blockedUserIds, + selected, + messages, + loading, + error, + bootstrap, + refreshOnline, + setOnlineStatus, + createGroup, + addMemberToGroup, + blockUser, + unblockUser, + selectChat, + sendMessage, + sendMedia, + sendLocation, + sendTyping, + appendIncoming + } = useChatStore(); + + const [draft, setDraft] = useState(""); + const [groupName, setGroupName] = useState(""); + const [groupMembers, setGroupMembers] = useState([]); + const [newMemberId, setNewMemberId] = useState(""); + const [typingFromUserId, setTypingFromUserId] = useState(null); + const [recording, setRecording] = useState(false); + const [showAttachMenu, setShowAttachMenu] = useState(false); + const [showEmojiMenu, setShowEmojiMenu] = useState(false); + const [showProfileModal, setShowProfileModal] = useState(false); + const [activeProfile, setActiveProfile] = useState(null); + const [profileUsername, setProfileUsername] = useState(""); + const [profileFullName, setProfileFullName] = useState(""); + const [profileBio, setProfileBio] = useState(""); + const [profileAvatarUrl, setProfileAvatarUrl] = useState(""); + const typingTimerRef = useRef | null>(null); + const mediaInputRef = useRef(null); + const documentInputRef = useRef(null); + const avatarInputRef = useRef(null); + const mediaRecorderRef = useRef(null); + const mediaChunksRef = useRef([]); + + useEffect(() => { + if (!user) return; + const resolvedUserId = typeof user.id === "number" ? user.id : decodeUserIdFromToken(user.token); + if (!resolvedUserId) return; + void bootstrap(resolvedUserId); + }, [user, bootstrap]); + + useEffect(() => { + if (!user) return; + void refreshOnline(); + const timer = setInterval(() => { + void refreshOnline(); + }, 15000); + return () => clearInterval(timer); + }, [user, refreshOnline]); + + const handleRealtimeEvent = useCallback((event: RealtimeEvent) => { + if (event.type === "message") { + appendIncoming(event); + return; + } + + if (event.type === "typing") { + setTypingFromUserId(event.from); + if (typingTimerRef.current) { + clearTimeout(typingTimerRef.current); + } + typingTimerRef.current = setTimeout(() => { + setTypingFromUserId(null); + }, 2500); + return; + } + + if (event.type === "user_online") { + setOnlineStatus(event.userId, true); + return; + } + + if (event.type === "user_offline") { + setOnlineStatus(event.userId, false); + } + }, [appendIncoming, setOnlineStatus]); + + useEventStream({ userId: user?.id, onEvent: handleRealtimeEvent }); + + const contactItems = useMemo( + () => + users.map((entry) => ({ + kind: "user" as const, + id: entry.id, + label: entry.fullName || entry.username, + online: onlineUserIds.includes(entry.id) + })), + [users, onlineUserIds] + ); + + const groupItems = useMemo( + () => + groups.map((group) => ({ + kind: "group" as const, + id: group.id, + label: `# ${group.name}` + })), + [groups] + ); + + if (!user) return null; + const currentUserId = typeof user.id === "number" ? user.id : decodeUserIdFromToken(user.token); + if (!currentUserId) return null; + + const onSelect = (target: ChatTarget) => { + void selectChat(target, currentUserId); + }; + + const onSend = (event: FormEvent) => { + event.preventDefault(); + void sendMessage(draft, currentUserId); + setDraft(""); + }; + + const selectedGroup = + selected?.kind === "group" ? groups.find((group) => group.id === selected.id) ?? null : null; + + const selectedUserBlocked = selected?.kind === "user" ? blockedUserIds.includes(selected.id) : false; + + const submitGroup = (event: FormEvent) => { + event.preventDefault(); + void createGroup(groupName, groupMembers, currentUserId); + setGroupName(""); + setGroupMembers([]); + }; + + const addMember = () => { + if (!selectedGroup || !newMemberId) return; + void addMemberToGroup(selectedGroup.id, Number(newMemberId), currentUserId); + setNewMemberId(""); + }; + + const typingVisible = + selected?.kind === "user" && typingFromUserId === selected.id && !selectedUserBlocked; + + const inferMediaType = (file: File) => { + if (file.type.startsWith("image/")) return "image"; + if (file.type.startsWith("video/")) return "video"; + if (file.type.startsWith("audio/")) return "audio"; + return "file"; + }; + + const handleFileChange = async (event: ChangeEvent) => { + if (!user || !event.target.files?.length) return; + const file = event.target.files[0]; + try { + const uploaded = await api.upload(file); + await sendMedia(uploaded.url, inferMediaType(file), currentUserId); + } finally { + event.target.value = ""; + setShowAttachMenu(false); + } + }; + + const startRecording = async () => { + if (recording || !user) return; + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + mediaRecorderRef.current = recorder; + mediaChunksRef.current = []; + + recorder.ondataavailable = (e) => { + if (e.data.size > 0) { + mediaChunksRef.current.push(e.data); + } + }; + + recorder.onstop = async () => { + const blob = new Blob(mediaChunksRef.current, { type: "audio/webm" }); + const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" }); + const uploaded = await api.upload(file); + await sendMedia(uploaded.url, "audio", currentUserId); + stream.getTracks().forEach((track) => track.stop()); + setRecording(false); + }; + + recorder.start(); + setRecording(true); + }; + + const stopRecording = () => { + mediaRecorderRef.current?.stop(); + }; + + const shareLocation = () => { + if (!user || !navigator.geolocation) return; + navigator.geolocation.getCurrentPosition((position) => { + void sendLocation(position.coords.latitude, position.coords.longitude, currentUserId); + }); + setShowAttachMenu(false); + }; + + const emojiItems = ["😀", "😂", "😍", "🙏", "👍", "🔥", "❤️", "🎉", "😢", "🤝"]; + + const openProfile = async (targetUserId?: number) => { + const resolvedUserId = typeof targetUserId === "number" ? targetUserId : currentUserId; + if (!resolvedUserId) return; + const fallbackUser = [user, ...users].find((entry) => entry && entry.id === resolvedUserId) || null; + const profile = await api.profile(resolvedUserId).catch(() => fallbackUser); + if (!profile) return; + setActiveProfile(profile); + setProfileUsername(profile.username || ""); + setProfileFullName(profile.fullName || ""); + setProfileBio(profile.bio || ""); + setProfileAvatarUrl(profile.avatarUrl || ""); + setShowProfileModal(true); + }; + + const onProfileSave = async (event: FormEvent) => { + event.preventDefault(); + if (!activeProfile || activeProfile.id !== currentUserId) { + setShowProfileModal(false); + return; + } + const ok = await updateProfile({ + username: profileUsername.trim(), + fullName: profileFullName.trim(), + bio: profileBio.trim(), + avatarUrl: profileAvatarUrl.trim() + }); + if (ok) { + setActiveProfile({ + ...activeProfile, + username: profileUsername.trim(), + fullName: profileFullName.trim(), + bio: profileBio.trim(), + avatarUrl: profileAvatarUrl.trim() + }); + void bootstrap(currentUserId); + setShowProfileModal(false); + } + }; + + const onAvatarChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const uploaded = await api.upload(file); + setProfileAvatarUrl(uploaded.url); + event.target.value = ""; + }; + + const isOwnProfile = !!(activeProfile && activeProfile.id === currentUserId); + + return ( +
+ + +
+
+

{selected ? selected.label : "Select a chat"}

+ {selected?.kind === "user" ? ( +
+ {onlineUserIds.includes(selected.id) ? "Online" : "Offline"} + + +
+ ) : null} + {selectedGroup && selectedGroup.creator === currentUserId ? ( +
+ + +
+ ) : null} +
+ +
+ {!selected ?

Choose a contact or group to start messaging.

: null} + {typingVisible ?

User is typing...

: null} + {error ?

{error}

: null} + {loading ? : null} + + {messages.map((msg) => { + const mine = msg.from === currentUserId; + return ( +
+ {msg.content ?

{msg.content}

: null} + {msg.mediaUrl && msg.mediaType === "image" ? image : null} + {msg.mediaUrl && msg.mediaType === "audio" ?
+ ); + })} +
+ +
+ + + +
+ + + {showAttachMenu && selected ? ( +
+ + + +
+ ) : null} +
+ + { + setDraft(e.target.value); + void sendTyping(currentUserId); + }} + disabled={!selected} + placeholder={selected ? "Type your message..." : "Select a chat first"} + /> + +
+ + {showEmojiMenu && selected ? ( +
+ {emojiItems.map((emoji) => ( + + ))} +
+ ) : null} +
+ + +
+ + {showProfileModal && activeProfile ? ( +
setShowProfileModal(false)}> +
e.stopPropagation()}> +

Profile

+
+ +
+ {profileAvatarUrl ? ( + avatar + ) : ( +
◯
+ )} + {isOwnProfile ? ( + + ) : null} +
+ + + + + + +
+ + {isOwnProfile ? : null} +
+
+
+
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/hooks/useEventStream.ts b/frontend/src/hooks/useEventStream.ts new file mode 100644 index 0000000..ff51b03 --- /dev/null +++ b/frontend/src/hooks/useEventStream.ts @@ -0,0 +1,52 @@ +import { useEffect } from "react"; +import type { RealtimeEvent } from "../types"; + +type UseEventStreamOptions = { + userId?: number; + onEvent: (payload: RealtimeEvent) => void; +}; + +export function useEventStream({ userId, onEvent }: UseEventStreamOptions) { + useEffect(() => { + if (!userId) { + return; + } + + let source: EventSource | null = null; + let reconnectTimer: ReturnType | null = null; + let disposed = false; + + const connect = () => { + if (disposed) return; + + source = new EventSource(`/events?userId=${userId}`); + source.onmessage = (event) => { + try { + const payload = JSON.parse(event.data) as RealtimeEvent; + if (payload && typeof payload === "object" && "type" in payload) { + onEvent(payload); + } + } catch { + // Ignore malformed events + } + }; + + source.onerror = () => { + source?.close(); + if (!disposed) { + reconnectTimer = setTimeout(connect, 2500); + } + }; + }; + + connect(); + + return () => { + disposed = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + source?.close(); + }; + }, [userId, onEvent]); +} diff --git a/frontend/src/lib/http.ts b/frontend/src/lib/http.ts new file mode 100644 index 0000000..a18b5cc --- /dev/null +++ b/frontend/src/lib/http.ts @@ -0,0 +1,37 @@ +import axios, { AxiosError } from "axios"; +import type { ApiError } from "../types"; + +const http = axios.create({ + baseURL: "/", + timeout: 15000, + headers: { + "Content-Type": "application/json" + } +}); + +http.interceptors.request.use((config) => { + const token = localStorage.getItem("authToken"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +export function toApiError(error: unknown): ApiError { + const fallback: ApiError = { error: "Unexpected error" }; + if (!(error instanceof AxiosError)) return fallback; + + const body = error.response?.data; + if (typeof body === "string") { + return { error: body }; + } + + if (body && typeof body === "object" && "error" in body) { + const payload = body as ApiError; + return { error: payload.error ?? fallback.error, code: payload.code }; + } + + return { error: error.message || fallback.error }; +} + +export { http }; \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..f4a05c0 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..79e5936 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,104 @@ +import { http } from "../lib/http"; +import type { Group, Message, SendMessageRequest, User } from "../types"; + +export const api = { + async register(payload: { + username: string; + fullName: string; + password: string; + }): Promise { + const { data } = await http.post("/api/register", payload); + return data; + }, + + async login(payload: { username: string; password: string }): Promise { + const { data } = await http.post("/api/login", payload); + return data; + }, + + async users(): Promise { + const { data } = await http.get("/api/users"); + return data; + }, + + async profile(userId: number): Promise { + const { data } = await http.get(`/api/profile?userId=${userId}`); + return data; + }, + + async updateProfile(payload: { + userId: number; + username: string; + fullName: string; + bio: string; + avatarUrl: string; + }): Promise { + const { data } = await http.post("/api/profile", payload); + return data; + }, + + async groups(userId: number): Promise { + const { data } = await http.get(`/api/groups?userId=${userId}`); + return data; + }, + + async createGroup(payload: { name: string; creatorId: number; memberIds: number[] }): Promise { + const { data } = await http.post("/api/groups", payload); + return data; + }, + + async addGroupMember(payload: { groupId: number; userId: number; adderId: number }): Promise<{ status: string }> { + const { data } = await http.post<{ status: string }>("/api/group/add", payload); + return data; + }, + + async blocked(userId: number): Promise { + const { data } = await http.get(`/api/blocked?userId=${userId}`); + return data; + }, + + async block(blockerId: number, blockedId: number): Promise<{ status: string }> { + const { data } = await http.post<{ status: string }>("/api/block", { blockerId, blockedId }); + return data; + }, + + async unblock(blockerId: number, blockedId: number): Promise<{ status: string }> { + const { data } = await http.post<{ status: string }>("/api/unblock", { blockerId, blockedId }); + return data; + }, + + async online(): Promise { + const { data } = await http.get("/api/online"); + return data; + }, + + async typing(from: number, to: number): Promise<{ status: string }> { + const { data } = await http.post<{ status: string }>("/api/typing", { from, to }); + return data; + }, + + async upload(file: File): Promise<{ url: string }> { + const formData = new FormData(); + formData.append("file", file); + const { data } = await http.post<{ url: string }>("/api/upload", formData, { + headers: { "Content-Type": "multipart/form-data" } + }); + return data; + }, + + async messages(params: { userId: number; contactId?: number; groupId?: number }): Promise { + const query = new URLSearchParams({ userId: String(params.userId) }); + if (params.contactId) query.set("contactId", String(params.contactId)); + if (params.groupId) query.set("groupId", String(params.groupId)); + const { data } = await http.get(`/api/messages?${query.toString()}`); + return data; + }, + + async send(payload: SendMessageRequest): Promise<{ status: string; messageId: number }> { + const { data } = await http.post<{ status: string; messageId: number }>("/api/send", { + ...payload, + type: "message" + }); + return data; + } +}; \ No newline at end of file diff --git a/frontend/src/store.ts b/frontend/src/store.ts new file mode 100644 index 0000000..7b35217 --- /dev/null +++ b/frontend/src/store.ts @@ -0,0 +1,413 @@ +import { create } from "zustand"; +import { api } from "./services/api"; +import { toApiError } from "./lib/http"; +import type { ChatTarget, Group, Message, User } from "./types"; + +function decodeUserIdFromToken(token?: string | null): number | null { + if (!token) return null; + try { + const payloadPart = token.split(".")[1]; + if (!payloadPart) return null; + const json = JSON.parse(atob(payloadPart)); + const userId = Number(json.userId); + return Number.isFinite(userId) ? userId : null; + } catch { + return null; + } +} + +type AuthState = { + user: User | null; + loading: boolean; + error: string | null; + login: (username: string, password: string) => Promise; + register: (username: string, fullName: string, password: string) => Promise; + updateProfile: (payload: { username: string; fullName: string; bio: string; avatarUrl: string }) => Promise; + hydrate: () => void; + logout: () => void; +}; + +type ChatState = { + users: User[]; + groups: Group[]; + onlineUserIds: number[]; + blockedUserIds: number[]; + selected: ChatTarget | null; + messages: Message[]; + loading: boolean; + error: string | null; + bootstrap: (userId: number) => Promise; + refreshOnline: () => Promise; + setOnlineStatus: (userId: number, online: boolean) => void; + createGroup: (name: string, memberIds: number[], userId: number) => Promise; + addMemberToGroup: (groupId: number, targetUserId: number, userId: number) => Promise; + blockUser: (targetUserId: number, userId: number) => Promise; + unblockUser: (targetUserId: number, userId: number) => Promise; + selectChat: (target: ChatTarget, userId: number) => Promise; + sendMessage: (content: string, userId: number) => Promise; + sendMedia: (mediaUrl: string, mediaType: string, userId: number) => Promise; + sendLocation: (latitude: number, longitude: number, userId: number) => Promise; + sendTyping: (userId: number) => Promise; + appendIncoming: (message: Message) => void; +}; + +type AuthSetter = (partial: Partial | ((state: AuthState) => Partial)) => void; +type AuthGetter = () => AuthState; +type ChatSetter = (partial: Partial | ((state: ChatState) => Partial)) => void; +type ChatGetter = () => ChatState; + +export const useAuthStore = create((set: AuthSetter, get: AuthGetter) => ({ + user: null, + loading: false, + error: null, + + hydrate: () => { + const raw = localStorage.getItem("user"); + const token = localStorage.getItem("authToken"); + if (!raw) return; + + try { + const parsed = JSON.parse(raw) as Partial; + let userId = typeof parsed.id === "number" ? parsed.id : null; + if (!userId) { + userId = decodeUserIdFromToken(token); + } + + if (!userId) { + localStorage.removeItem("user"); + localStorage.removeItem("authToken"); + set({ user: null, error: null }); + return; + } + + const mergedUser: User = { + id: userId, + username: parsed.username || "", + fullName: parsed.fullName || "", + bio: parsed.bio || "", + avatarUrl: parsed.avatarUrl || "", + token: parsed.token || token || undefined + }; + + set({ user: mergedUser }); + + void api.profile(userId) + .then((profile) => { + const current = get().user; + if (!current || current.id !== userId) return; + const refreshed: User = { + ...current, + ...profile, + token: current.token + }; + localStorage.setItem("user", JSON.stringify(refreshed)); + set({ user: refreshed, error: null }); + }) + .catch(() => { + // keep local cached user + }); + } catch { + localStorage.removeItem("user"); + localStorage.removeItem("authToken"); + } + }, + + login: async (username: string, password: string) => { + set({ loading: true, error: null }); + try { + const user = await api.login({ username, password }); + localStorage.setItem("user", JSON.stringify(user)); + if (user.token) localStorage.setItem("authToken", user.token); + set({ user, loading: false, error: null }); + return true; + } catch (error) { + set({ loading: false, error: toApiError(error).error }); + return false; + } + }, + + register: async (username: string, fullName: string, password: string) => { + set({ loading: true, error: null }); + try { + await api.register({ username, fullName, password }); + set({ loading: false, error: null }); + return true; + } catch (error) { + set({ loading: false, error: toApiError(error).error }); + return false; + } + }, + + updateProfile: async (payload: { username: string; fullName: string; bio: string; avatarUrl: string }) => { + const currentUser = JSON.parse(localStorage.getItem("user") || "null") as User | null; + if (!currentUser) { + set({ error: "Not authenticated" }); + return false; + } + + set({ loading: true, error: null }); + try { + const updated = await api.updateProfile({ + userId: currentUser.id, + username: payload.username, + fullName: payload.fullName, + bio: payload.bio, + avatarUrl: payload.avatarUrl + }); + + const mergedUser: User = { ...currentUser, ...updated }; + localStorage.setItem("user", JSON.stringify(mergedUser)); + set({ user: mergedUser, loading: false, error: null }); + return true; + } catch (error) { + set({ loading: false, error: toApiError(error).error }); + return false; + } + }, + + logout: () => { + localStorage.removeItem("user"); + localStorage.removeItem("authToken"); + set({ user: null, error: null }); + } +})); + +export const useChatStore = create((set: ChatSetter, get: ChatGetter) => ({ + users: [], + groups: [], + onlineUserIds: [], + blockedUserIds: [], + selected: null, + messages: [], + loading: false, + error: null, + + bootstrap: async (userId: number) => { + set({ loading: true, error: null }); + try { + const [users, groups, blockedUserIds, onlineUserIds] = await Promise.all([ + api.users(), + api.groups(userId), + api.blocked(userId), + api.online() + ]); + set({ + users: users.filter((u) => u.id !== userId), + groups, + blockedUserIds, + onlineUserIds, + loading: false, + error: null + }); + } catch (error) { + set({ loading: false, error: toApiError(error).error }); + } + }, + + refreshOnline: async () => { + try { + const onlineUserIds = await api.online(); + set({ onlineUserIds }); + } catch { + // Presence is best-effort. + } + }, + + setOnlineStatus: (userId: number, online: boolean) => { + set((state: ChatState) => { + const exists = state.onlineUserIds.includes(userId); + if (online && !exists) { + return { onlineUserIds: [...state.onlineUserIds, userId] }; + } + if (!online && exists) { + return { onlineUserIds: state.onlineUserIds.filter((id: number) => id !== userId) }; + } + return state; + }); + }, + + createGroup: async (name: string, memberIds: number[], userId: number) => { + if (!name.trim()) { + set({ error: "Group name is required" }); + return; + } + + try { + await api.createGroup({ name: name.trim(), creatorId: userId, memberIds }); + const groups = await api.groups(userId); + set({ groups, error: null }); + } catch (error) { + set({ error: toApiError(error).error }); + } + }, + + addMemberToGroup: async (groupId: number, targetUserId: number, userId: number) => { + try { + await api.addGroupMember({ groupId, userId: targetUserId, adderId: userId }); + set({ error: null }); + } catch (error) { + set({ error: toApiError(error).error }); + } + }, + + blockUser: async (targetUserId: number, userId: number) => { + try { + await api.block(userId, targetUserId); + set((state: ChatState) => ({ + blockedUserIds: state.blockedUserIds.includes(targetUserId) + ? state.blockedUserIds + : [...state.blockedUserIds, targetUserId], + error: null + })); + } catch (error) { + set({ error: toApiError(error).error }); + } + }, + + unblockUser: async (targetUserId: number, userId: number) => { + try { + await api.unblock(userId, targetUserId); + set((state: ChatState) => ({ + blockedUserIds: state.blockedUserIds.filter((id: number) => id !== targetUserId), + error: null + })); + } catch (error) { + set({ error: toApiError(error).error }); + } + }, + + selectChat: async (target: ChatTarget, userId: number) => { + set({ selected: target, loading: true, error: null }); + try { + const messages = + target.kind === "user" + ? await api.messages({ userId, contactId: target.id }) + : await api.messages({ userId, groupId: target.id }); + set({ messages, loading: false, error: null }); + } catch (error) { + set({ loading: false, error: toApiError(error).error }); + } + }, + + sendMessage: async (content: string, userId: number) => { + const selected = get().selected; + if (!selected || !content.trim()) return; + + if (selected.kind === "user" && get().blockedUserIds.includes(selected.id)) { + set({ error: "This user is blocked" }); + return; + } + + const payload = + selected.kind === "user" + ? { from: userId, to: selected.id, content: content.trim() } + : { from: userId, groupId: selected.id, content: content.trim() }; + + try { + const response = await api.send(payload); + const optimistic: Message = { + id: response.messageId, + type: "message", + from: userId, + to: selected.kind === "user" ? selected.id : undefined, + groupId: selected.kind === "group" ? selected.id : undefined, + content: content.trim(), + timestamp: Math.floor(Date.now() / 1000) + }; + set((state: ChatState) => ({ messages: [...state.messages, optimistic] })); + } catch (error) { + set({ error: toApiError(error).error }); + } + }, + + sendMedia: async (mediaUrl: string, mediaType: string, userId: number) => { + const selected = get().selected; + if (!selected) return; + + if (selected.kind === "user" && get().blockedUserIds.includes(selected.id)) { + set({ error: "This user is blocked" }); + return; + } + + const payload = + selected.kind === "user" + ? { from: userId, to: selected.id, mediaUrl, mediaType } + : { from: userId, groupId: selected.id, mediaUrl, mediaType }; + + try { + const response = await api.send(payload); + const optimistic: Message = { + id: response.messageId, + type: "message", + from: userId, + to: selected.kind === "user" ? selected.id : undefined, + groupId: selected.kind === "group" ? selected.id : undefined, + mediaUrl, + mediaType, + timestamp: Math.floor(Date.now() / 1000) + }; + set((state: ChatState) => ({ messages: [...state.messages, optimistic], error: null })); + } catch (error) { + set({ error: toApiError(error).error }); + } + }, + + sendLocation: async (latitude: number, longitude: number, userId: number) => { + const selected = get().selected; + if (!selected) return; + + if (selected.kind === "user" && get().blockedUserIds.includes(selected.id)) { + set({ error: "This user is blocked" }); + return; + } + + const payload = + selected.kind === "user" + ? { from: userId, to: selected.id, content: "Location", latitude, longitude } + : { from: userId, groupId: selected.id, content: "Location", latitude, longitude }; + + try { + const response = await api.send(payload); + const optimistic: Message = { + id: response.messageId, + type: "message", + from: userId, + to: selected.kind === "user" ? selected.id : undefined, + groupId: selected.kind === "group" ? selected.id : undefined, + content: "Location", + latitude, + longitude, + timestamp: Math.floor(Date.now() / 1000) + }; + set((state: ChatState) => ({ messages: [...state.messages, optimistic], error: null })); + } catch (error) { + set({ error: toApiError(error).error }); + } + }, + + sendTyping: async (userId: number) => { + const selected = get().selected; + if (!selected || selected.kind !== "user") { + return; + } + + try { + await api.typing(userId, selected.id); + } catch { + // best-effort signal + } + }, + + appendIncoming: (message: Message) => { + const selected = get().selected; + if (!selected) return; + + const belongsToCurrentChat = + selected.kind === "group" + ? message.groupId === selected.id + : (message.from === selected.id || message.to === selected.id) && !message.groupId; + + if (!belongsToCurrentChat) return; + set((state: ChatState) => ({ messages: [...state.messages, message] })); + } +})); \ No newline at end of file diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..8146608 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,457 @@ +:root { + color-scheme: dark; + --bg: #101010; + --panel: #161616; + --panel-2: #1e1e1e; + --text: #f3f3f3; + --muted: #b6b6b6; + --primary: #2a2a2a; + --danger: #c8c8c8; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + width: 100%; + height: 100%; + background: var(--bg); + color: var(--text); +} + +button, +input { + font: inherit; +} + +.auth-screen { + min-height: 100%; + display: grid; + place-items: center; + padding: 16px; +} + +.auth-card { + width: min(100%, 420px); + background: var(--panel); + border: 1px solid #2a2a2a; + border-radius: 12px; + padding: 20px; + display: grid; + gap: 12px; +} + +.auth-card h1 { + margin: 0; +} + +.subtitle { + color: var(--muted); + margin: 0 0 8px; +} + +label { + display: grid; + gap: 6px; + font-size: 14px; +} + +input { + border: 1px solid #2a2a2a; + background: #141414; + color: var(--text); + border-radius: 8px; + padding: 10px 12px; +} + +button { + border: 1px solid #2f2f2f; + border-radius: 8px; + padding: 10px 12px; + background: var(--primary); + color: var(--text); + cursor: pointer; +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.switch-mode { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.link-btn { + background: transparent; + color: var(--text); + padding: 0; +} + +.error { + color: var(--danger); + margin: 0; + font-size: 14px; +} + +.chat-layout { + height: 100%; + display: grid; + grid-template-columns: 320px 1fr; +} + +.sidebar { + border-right: 1px solid #2a2a2a; + background: var(--panel); + display: flex; + flex-direction: column; + min-height: 0; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px; + border-bottom: 1px solid #2a2a2a; +} + +.profile-chip { + display: inline-flex; + align-items: center; + gap: 8px; + background: transparent; + border: 0; + padding: 0; +} + +.profile-chip-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; +} + +.sidebar-header h2 { + margin: 0; + font-size: 18px; +} + +.secondary { + background: #1b1b1b; +} + +.nav-list { + display: grid; + gap: 4px; + padding: 8px; + overflow: auto; +} + +.group-creator { + display: grid; + gap: 8px; + padding: 10px; + border-bottom: 1px solid #2a2a2a; +} + +.member-picks { + display: grid; + gap: 4px; + max-height: 120px; + overflow: auto; + border: 1px solid #2a2a2a; + border-radius: 8px; + padding: 8px; + background: #141414; +} + +.member-pick { + display: flex; + gap: 8px; + align-items: center; + font-size: 13px; +} + +.nav-item { + text-align: left; + background: var(--panel-2); + border: 1px solid #2a2a2a; +} + +.nav-item.active { + outline: 1px solid #6d6d6d; +} + +.conversation { + display: grid; + grid-template-rows: auto 1fr auto; + min-height: 0; +} + +.conversation-header { + padding: 14px; + border-bottom: 1px solid #2a2a2a; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} + +.conversation-header h3 { + margin: 0; +} + +.header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.presence-badge { + font-size: 12px; + color: var(--muted); + border: 1px solid #2a2a2a; + border-radius: 999px; + padding: 4px 8px; +} + +.typing-note { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.message-feed { + overflow: auto; + display: grid; + align-content: start; + gap: 8px; + padding: 14px; +} + +.placeholder { + color: var(--muted); + margin: 0; +} + +.bubble { + max-width: min(75%, 560px); + padding: 10px 12px; + border-radius: 12px; + line-height: 1.35; +} + +.bubble p { + margin: 0; +} + +.bubble.mine { + margin-left: auto; + background: #272727; +} + +.bubble.their { + background: #1f1f1f; +} + +.composer { + display: grid; + grid-template-columns: auto 1fr auto auto auto; + gap: 8px; + border-top: 1px solid #2a2a2a; + padding: 12px; + align-items: center; +} + +.composer input { + height: 44px; + border-radius: 999px; + background: #1b1f24; +} + +.composer-attach { + position: relative; +} + +.emoji-wrap { + position: relative; +} + +.icon-btn { + width: 44px; + height: 44px; + border-radius: 999px; + background: #1b1f24; + border: 1px solid #2f2f2f; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.attach-menu { + position: absolute; + left: 0; + bottom: 52px; + min-width: 230px; + background: #242a31; + border: 1px solid #363d45; + border-radius: 16px; + padding: 8px; + display: grid; + gap: 4px; + z-index: 10; +} + +.attach-item { + width: 100%; + text-align: left; + background: transparent; + border: 0; + border-radius: 10px; + padding: 10px; + display: flex; + align-items: center; + gap: 10px; +} + +.attach-item:hover { + background: #2b323a; +} + +.send-btn-min { + width: 40px; + height: 40px; + border-radius: 999px; + background: #2b323a; + border: 1px solid #3a424b; + padding: 0; +} + +.emoji-menu { + position: absolute; + right: 0; + bottom: 52px; + min-width: 190px; + background: #242a31; + border: 1px solid #363d45; + border-radius: 14px; + padding: 8px; + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 4px; + z-index: 10; +} + +.emoji-item { + background: transparent; + border: 0; + padding: 6px; + font-size: 18px; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 20; +} + +.profile-modal { + width: min(100%, 420px); + background: #191919; + border: 1px solid #2f2f2f; + border-radius: 14px; + padding: 14px; +} + +.profile-modal h4 { + margin: 0 0 10px; +} + +.profile-form { + display: grid; + gap: 10px; +} + +.profile-avatar-row { + display: flex; + align-items: center; + gap: 10px; +} + +.profile-avatar { + width: 52px; + height: 52px; + border-radius: 50%; + object-fit: cover; +} + +.profile-avatar-empty { + background: #262626; + border: 1px solid #333; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.profile-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.section-title { + margin: 8px 4px 2px; + font-size: 12px; + color: var(--muted); +} + +.status-dot-inline { + display: inline-block; + width: 14px; +} + +.media-preview { + max-width: 260px; + border-radius: 8px; + border: 1px solid #2a2a2a; +} + +.media-audio { + width: 240px; +} + +.media-link { + color: var(--text); + text-decoration: underline; + font-size: 13px; +} + +@media (max-width: 900px) { + .chat-layout { + grid-template-columns: 1fr; + } + + .sidebar { + max-height: 45vh; + border-right: 0; + border-bottom: 1px solid #2a2a2a; + } + + .bubble { + max-width: 88%; + } +} \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..e741bfd --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,64 @@ +export type ApiError = { + error: string; + code?: string; +}; + +export type User = { + id: number; + username: string; + fullName: string; + bio?: string; + avatarUrl?: string; + token?: string; +}; + +export type Group = { + id: number; + name: string; + creator: number; +}; + +export type Message = { + id?: number; + type: "message"; + content?: string; + from: number; + to?: number; + groupId?: number; + mediaUrl?: string; + mediaType?: string; + latitude?: number; + longitude?: number; + timestamp?: number; +}; + +export type SendMessageRequest = { + from: number; + to?: number; + groupId?: number; + content?: string; + mediaUrl?: string; + mediaType?: string; + latitude?: number; + longitude?: number; +}; + +export type ChatTarget = + | { kind: "user"; id: number; label: string } + | { kind: "group"; id: number; label: string }; + +export type TypingEvent = { + type: "typing"; + from: number; +}; + +export type OnlineEvent = { + type: "user_online" | "user_offline"; + userId: number; +}; + +export type ConnectedEvent = { + type: "connected"; +}; + +export type RealtimeEvent = Message | TypingEvent | OnlineEvent | ConnectedEvent; \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a552cd5 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..5c4fbbc --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +const proxyTarget = process.env.VITE_PROXY_TARGET || "http://localhost:8080"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": proxyTarget, + "/events": proxyTarget, + "/uploads": proxyTarget + } + } +}); \ No newline at end of file diff --git a/server.go b/server.go deleted file mode 100644 index 9523adf..0000000 --- a/server.go +++ /dev/null @@ -1,163 +0,0 @@ -package main - -import ( - "bufio" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "os" - "path/filepath" - "strings" - "time" -) - -// Message is the JSON structure exchanged between client and server -type Message struct { - Type string `json:"type"` - First string `json:"first,omitempty"` - Last string `json:"last,omitempty"` - Text string `json:"text,omitempty"` - URL string `json:"url,omitempty"` - MediaType string `json:"mediaType,omitempty"` -} - -// Store connected clients with their display name -var clients = make(map[net.Conn]string) - -func handleConnection(conn net.Conn) { - defer conn.Close() - - // default name until registration - clients[conn] = conn.RemoteAddr().String() - fmt.Fprintf(conn, "{\"type\":\"info\",\"text\":\"Connected. Send a register message with type=register, first and last.\"}\n") - - scanner := bufio.NewScanner(conn) - for scanner.Scan() { - line := scanner.Bytes() - var msg Message - if err := json.Unmarshal(line, &msg); err != nil { - // ignore invalid JSON - continue - } - - switch msg.Type { - case "register": - name := strings.TrimSpace(msg.First + " " + msg.Last) - if name == "" { - name = conn.RemoteAddr().String() - } - clients[conn] = name - // confirm to this client - resp := Message{Type: "info", Text: "Registered as: " + name} - sendJSON(conn, resp) - case "text", "media": - // attach sender name and broadcast - broadcastMsg := Message{ - Type: msg.Type, - First: clients[conn], - Text: msg.Text, - URL: msg.URL, - MediaType: msg.MediaType, - } - broadcast(conn, broadcastMsg) - default: - // unknown type - ignore - } - } - - delete(clients, conn) -} - -func sendJSON(conn net.Conn, msg Message) { - b, _ := json.Marshal(msg) - fmt.Fprint(conn, string(b)+"\n") -} - -// Broadcast a message to all clients except the sender -func broadcast(sender net.Conn, message Message) { - for client := range clients { - if client != sender { - sendJSON(client, message) - } - } -} - -// uploadHandler accepts multipart form file field "file" and saves it under ./uploads -func uploadHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - err := r.ParseMultipartForm(50 << 20) // 50MB - if err != nil { - http.Error(w, "failed to parse multipart form", http.StatusBadRequest) - return - } - - file, header, err := r.FormFile("file") - if err != nil { - http.Error(w, "file field is required", http.StatusBadRequest) - return - } - defer file.Close() - - if _, err := os.Stat("uploads"); os.IsNotExist(err) { - os.Mkdir("uploads", 0755) - } - - ext := filepath.Ext(header.Filename) - safeName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) - dstPath := filepath.Join("uploads", safeName) - dst, err := os.Create(dstPath) - if err != nil { - http.Error(w, "failed to save file", http.StatusInternalServerError) - return - } - defer dst.Close() - - if _, err := io.Copy(dst, file); err != nil { - http.Error(w, "failed to save file", http.StatusInternalServerError) - return - } - - // build file URL using the request Host - fileURL := fmt.Sprintf("http://%s/uploads/%s", r.Host, safeName) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"url": fileURL}) -} - -func main() { - // Start HTTP server for uploads and static files on 8081 - http.HandleFunc("/upload", uploadHandler) - fs := http.FileServer(http.Dir("uploads")) - http.Handle("/uploads/", http.StripPrefix("/uploads/", fs)) - go func() { - fmt.Println("HTTP upload server running on :8081 (uploads at /uploads/)") - if err := http.ListenAndServe(":8081", nil); err != nil { - fmt.Println("HTTP server error:", err) - os.Exit(1) - } - }() - - // Start TCP server on port 8080 - listener, err := net.Listen("tcp", ":8080") - if err != nil { - fmt.Println("Error starting server:", err) - os.Exit(1) - } - defer listener.Close() - - fmt.Println("TCP chat server is running on port 8080...") - - for { - conn, err := listener.Accept() - if err != nil { - fmt.Println("Error accepting connection:", err) - continue - } - go handleConnection(conn) - } -}