I am batman
This commit is contained in:
46
.air.toml
Normal file
46
.air.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./main"
|
||||
cmd = "make build"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
5
.github/instructions/00-main.md
vendored
Normal file
5
.github/instructions/00-main.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Backend Instruction Entry Point (Do Not Edit)
|
||||
|
||||
1. Read the user's prompt input completely before taking action.
|
||||
2. Act as a backend and database engineer for this repository's architecture.
|
||||
3. Update only the scoped instruction files in this directory when requested; do not modify this file.
|
||||
16
.github/instructions/api.md
vendored
Normal file
16
.github/instructions/api.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# API Instructions
|
||||
|
||||
## Scope
|
||||
- Applies to Fiber handlers and route registration under `internal/handlers` and `internal/server/routes.go`.
|
||||
|
||||
## Rules
|
||||
- Keep API contracts stable; if changing request/response shape, document backward compatibility in PR notes.
|
||||
- Prefer snake_case JSON request fields for backend payloads and accept legacy aliases only when needed.
|
||||
- Validate required fields explicitly in handlers and return clear `400` errors with actionable messages.
|
||||
- Return `404` for missing resources, `500` only for unexpected server/database failures.
|
||||
- Keep route naming consistent (`/resource`, `/resource/:id`) and group related routes in `routes.go`.
|
||||
|
||||
## Implementation Notes
|
||||
- Use `database.DB()` directly in handlers unless a service layer exists for that feature.
|
||||
- Preload only required relations for performance-sensitive list endpoints.
|
||||
- Keep list endpoints sorted deterministically (e.g., `position asc, id asc`).
|
||||
15
.github/instructions/containerization.md
vendored
Normal file
15
.github/instructions/containerization.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Containerization Instructions
|
||||
|
||||
## Scope
|
||||
- Applies to Dockerfiles, `docker-compose.yml`, and make targets that run services.
|
||||
|
||||
## Rules
|
||||
- Keep service startup deterministic: API, chat, webrtc, and seed images should build independently.
|
||||
- Avoid embedding secrets in Dockerfiles; rely on `.env` and compose environment wiring.
|
||||
- Preserve fast local iteration paths (`dev-local`, seed commands) when changing build contexts.
|
||||
- Keep images minimal and purpose-specific (`Dockerfile`, `Dockerfile.chat`, `Dockerfile.webrtc`, `Dockerfile.seed`).
|
||||
- If ports or health behavior change, update compose and README usage notes together.
|
||||
|
||||
## Operational Notes
|
||||
- Prefer non-destructive changes to compose volumes and DB state in dev.
|
||||
- When build/runtime differs between local and CI, document the reason in PR notes.
|
||||
15
.github/instructions/database.md
vendored
Normal file
15
.github/instructions/database.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Database Instructions
|
||||
|
||||
## Scope
|
||||
- Applies to GORM models in `internal/models` and migration behavior in `internal/database/database.go`.
|
||||
|
||||
## Rules
|
||||
- Define explicit foreign keys for non-trivial relations (`foreignKey`, `references`) to avoid inference errors.
|
||||
- Avoid introducing `NOT NULL` constraints on existing populated tables without a backfill plan.
|
||||
- Keep `AutoMigrate` additive-first; prefer safe schema transitions over breaking changes.
|
||||
- Use deterministic ordering fields (`position`) for UI-driven entities (kanban sections/tasks).
|
||||
- Ensure seed data aligns with current schema and relation requirements.
|
||||
|
||||
## Migration Safety
|
||||
- For legacy data, introduce nullable columns first, backfill, then tighten constraints in a later step.
|
||||
- Never assume empty tables in shared dev environments.
|
||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with "go test -c"
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
tmp/
|
||||
|
||||
# IDE specific files
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# .env file
|
||||
.env
|
||||
|
||||
# Project build
|
||||
main
|
||||
*templ.go
|
||||
|
||||
gitmvp.md
|
||||
TODO.md
|
||||
|
||||
Dockerfile
|
||||
Dockerfile.*
|
||||
|
||||
# OS X generated file
|
||||
.DS_Store
|
||||
|
||||
go.mod
|
||||
go.sum
|
||||
|
||||
89
Makefile
Normal file
89
Makefile
Normal file
@@ -0,0 +1,89 @@
|
||||
# Simple Makefile for a Go project
|
||||
|
||||
# Build the application
|
||||
all: build test
|
||||
|
||||
build:
|
||||
@echo "Building..."
|
||||
|
||||
|
||||
@go build -o main cmd/api/main.go
|
||||
|
||||
# Run the application
|
||||
run:
|
||||
@go run cmd/api/main.go
|
||||
# Create DB container
|
||||
docker-run:
|
||||
@if docker compose up --build 2>/dev/null; then \
|
||||
: ; \
|
||||
else \
|
||||
echo "Falling back to Docker Compose V1"; \
|
||||
docker compose up --build; \
|
||||
fi
|
||||
|
||||
# Shutdown DB container
|
||||
docker-down:
|
||||
@if docker compose down 2>/dev/null; then \
|
||||
: ; \
|
||||
else \
|
||||
echo "Falling back to Docker Compose V1"; \
|
||||
docker compose down; \
|
||||
fi
|
||||
|
||||
# Test the application
|
||||
test:
|
||||
@echo "Testing..."
|
||||
@go test ./... -v
|
||||
# Integrations Tests for the application
|
||||
itest:
|
||||
@echo "Running integration tests..."
|
||||
@go test ./internal/database -v
|
||||
|
||||
# Clean the binary
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@rm -f main
|
||||
|
||||
# Live Reload
|
||||
watch:
|
||||
@if command -v air > /dev/null; then \
|
||||
air; \
|
||||
echo "Watching...";\
|
||||
else \
|
||||
read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \
|
||||
if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \
|
||||
go install github.com/air-verse/air@latest; \
|
||||
air; \
|
||||
echo "Watching...";\
|
||||
else \
|
||||
echo "You chose not to install air. Exiting..."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# Start only the PostgreSQL container
|
||||
db-up:
|
||||
@if docker compose up -d psql_bp 2>/dev/null; then \
|
||||
: ; \
|
||||
else \
|
||||
echo "Falling back to Docker Compose V1"; \
|
||||
docker-compose up -d psql_bp; \
|
||||
fi
|
||||
|
||||
# Stop only the PostgreSQL container
|
||||
db-down:
|
||||
@if docker compose stop psql_bp 2>/dev/null; then \
|
||||
: ; \
|
||||
else \
|
||||
echo "Falling back to Docker Compose V1"; \
|
||||
docker-compose stop psql_bp; \
|
||||
fi
|
||||
|
||||
# Run DB in Docker and API locally with Air
|
||||
dev-local:
|
||||
@$(MAKE) db-up
|
||||
@$(MAKE) watch
|
||||
seed:
|
||||
@go run cmd/seed/main.go
|
||||
|
||||
.PHONY: all build run test clean watch docker-run docker-down db-up db-down dev-local itest seed
|
||||
162
README.md
Normal file
162
README.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# OpsMastery.v5
|
||||
|
||||
OpsMastery.v5 is a modular, production-ready platform for real-time chat, video/audio calling (WebRTC), and RESTful business operations. It is built in Go, uses JWT authentication, and is designed for easy integration with modern frontends (e.g., Next.js).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
OpsMastery.v5/
|
||||
├── cmd/
|
||||
│ ├── api/ # Main REST API server (users, tickets, auth, etc.)
|
||||
│ ├── chat-service/ # Real-time chat microservice (WebSocket)
|
||||
│ └── webrtc-service/ # WebRTC signaling microservice (WebSocket)
|
||||
├── internal/
|
||||
│ ├── chat_service/ # Chat logic
|
||||
│ ├── webrtc_service/ # WebRTC signaling logic
|
||||
│ ├── handlers/ # REST API handlers
|
||||
│ ├── models/ # Data models
|
||||
│ ├── database/ # DB connection and migrations
|
||||
│ ├── middleware/ # JWT and role middleware
|
||||
│ ├── server/ # REST API server setup
|
||||
│ └── utils/ # Utility functions (JWT, email, etc.)
|
||||
├── docker-compose.yml # Multi-service orchestration
|
||||
├── Dockerfile* # Dockerfiles for each service
|
||||
├── go.mod, go.sum # Go module files
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Services Overview
|
||||
|
||||
### 1. REST API (`cmd/api`)
|
||||
- Handles authentication, user management, tickets, and other business logic.
|
||||
- JWT-secured endpoints.
|
||||
- Integrates with PostgreSQL.
|
||||
|
||||
### 2. Chat Service (`cmd/chat-service`)
|
||||
- Real-time chat via WebSocket.
|
||||
- JWT authentication for all connections.
|
||||
- Broadcasts messages to all connected clients.
|
||||
- Persists chat messages in the database.
|
||||
|
||||
### 3. WebRTC Signaling Service (`cmd/webrtc-service`)
|
||||
- Handles signaling for video/audio calls (SDP/ICE exchange).
|
||||
- JWT authentication for all connections.
|
||||
- Designed for integration with WebRTC clients (e.g., browser, mobile).
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
- Designed to be consumed by any modern frontend (e.g., Next.js).
|
||||
- REST API: Use `fetch`/`axios` with JWT in cookies or headers.
|
||||
- Chat/WebRTC: Connect via WebSocket with JWT as a query param.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- [Go](https://golang.org/doc/install) >= 1.20
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Node.js](https://nodejs.org/) (for frontend, optional)
|
||||
|
||||
### Clone the Repository
|
||||
|
||||
```sh
|
||||
git clone https://github.com/gibbyDev/OpsMastery.v5.git
|
||||
cd OpsMastery.v5
|
||||
```
|
||||
|
||||
### Running with Docker Compose
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
- This will start the REST API, chat service, WebRTC signaling service, and PostgreSQL database.
|
||||
- Services are exposed on:
|
||||
- REST API: `http://localhost:8080`
|
||||
- Chat: `ws://localhost:5000/ws`
|
||||
- WebRTC: `ws://localhost:4000/ws`
|
||||
|
||||
### Running Locally (without Docker)
|
||||
|
||||
1. Start PostgreSQL (see `docker-compose.yml` for env vars).
|
||||
2. Run each service in a separate terminal:
|
||||
```sh
|
||||
go run cmd/api/main.go
|
||||
go run cmd/chat-service/main.go
|
||||
go run cmd/webrtc-service/main.go
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
- See `.env.example` or `docker-compose.yml` for required variables (DB connection, JWT secret, etc.).
|
||||
|
||||
## Development & Testing
|
||||
|
||||
- All code is in Go, organized for easy extension.
|
||||
- Unit and integration tests are in `internal/*/tests`.
|
||||
- Frontend integration examples available upon request.
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests and issues are welcome! Please open an issue for major changes.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Getting Started
|
||||
|
||||
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system.
|
||||
|
||||
## MakeFile
|
||||
|
||||
Run build make command with tests
|
||||
```bash
|
||||
make all
|
||||
```
|
||||
|
||||
Build the application
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
Run the application
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
Create DB container
|
||||
```bash
|
||||
make docker-run
|
||||
```
|
||||
|
||||
Run API locally with live reload while only starting Postgres in Docker:
|
||||
```bash
|
||||
make dev-local
|
||||
```
|
||||
|
||||
Shutdown DB Container
|
||||
```bash
|
||||
make docker-down
|
||||
```
|
||||
|
||||
Stop only the Postgres container:
|
||||
```bash
|
||||
make db-down
|
||||
```
|
||||
|
||||
DB Integrations Test:
|
||||
```bash
|
||||
make itest
|
||||
```
|
||||
|
||||
Live reload the application:
|
||||
```bash
|
||||
make watch
|
||||
```
|
||||
|
||||
Run the test suite:
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Clean up binary from the last build:
|
||||
```bash
|
||||
make clean
|
||||
```
|
||||
74
cmd/api/main.go
Normal file
74
cmd/api/main.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"OpsMastery.v5/internal/database"
|
||||
"OpsMastery.v5/internal/oauth"
|
||||
"OpsMastery.v5/internal/server"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
func gracefulShutdown(fiberServer *server.FiberServer, done chan bool) {
|
||||
// Create context that listens for the interrupt signal from the OS.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Listen for the interrupt signal.
|
||||
<-ctx.Done()
|
||||
|
||||
log.Println("shutting down gracefully, press Ctrl+C again to force")
|
||||
stop() // Allow Ctrl+C to force shutdown
|
||||
|
||||
// The context is used to inform the server it has 5 seconds to finish
|
||||
// the request it is currently handling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := fiberServer.ShutdownWithContext(ctx); err != nil {
|
||||
log.Printf("Server forced to shutdown with error: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server exiting")
|
||||
|
||||
// Notify the main goroutine that the shutdown is complete
|
||||
done <- true
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Initialize the database (if not already done elsewhere)
|
||||
database.Init()
|
||||
|
||||
// Initialize OAuth providers (Google, GitHub, etc.)
|
||||
oauth.InitProviders()
|
||||
|
||||
server := server.New()
|
||||
|
||||
// Pass the global db instance to RegisterFiberRoutes
|
||||
server.RegisterFiberRoutes(database.DB())
|
||||
|
||||
// Create a done channel to signal when the shutdown is complete
|
||||
done := make(chan bool, 1)
|
||||
|
||||
go func() {
|
||||
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
||||
err := server.Listen(fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("http server error: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
// Run graceful shutdown in a separate goroutine
|
||||
go gracefulShutdown(server, done)
|
||||
|
||||
// Wait for the graceful shutdown to complete
|
||||
<-done
|
||||
log.Println("Graceful shutdown complete.")
|
||||
}
|
||||
7
cmd/chat-service/main.go
Normal file
7
cmd/chat-service/main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "OpsMastery.v5/internal/chat_service"
|
||||
|
||||
func main() {
|
||||
chat_service.Start()
|
||||
}
|
||||
11
cmd/seed/main.go
Normal file
11
cmd/seed/main.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"OpsMastery.v5/dev"
|
||||
"OpsMastery.v5/internal/database"
|
||||
)
|
||||
|
||||
func main() {
|
||||
database.Init()
|
||||
dev.Seed(database.DB())
|
||||
}
|
||||
7
cmd/webrtc-service/main.go
Normal file
7
cmd/webrtc-service/main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "OpsMastery.v5/internal/webrtc_service"
|
||||
|
||||
func main() {
|
||||
webrtc_service.StartSignalingServer()
|
||||
}
|
||||
192
dev/seed.go
Normal file
192
dev/seed.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// File: dev/seed.go
|
||||
package dev
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"OpsMastery.v5/internal/models"
|
||||
"github.com/go-faker/faker/v4"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Helper to find a user by ID from a slice
|
||||
func findUserByID(users []models.User, id uint) models.User {
|
||||
for _, u := range users {
|
||||
if u.ID == id {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return models.User{}
|
||||
}
|
||||
|
||||
// Helper to get n random users from a slice
|
||||
func randomUsers(users []models.User, n int) []models.User {
|
||||
if len(users) < n {
|
||||
return users
|
||||
}
|
||||
rand.Shuffle(len(users), func(i, j int) { users[i], users[j] = users[j], users[i] })
|
||||
return users[:n]
|
||||
}
|
||||
|
||||
func Seed(db *gorm.DB) {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
fmt.Println("Seeding database...")
|
||||
|
||||
roles := []string{"User", "Admin", "Manager"}
|
||||
statuses := []string{"Open", "In Progress", "Resolved", "Closed"}
|
||||
priorities := []string{"Low", "Normal", "High", "Urgent"}
|
||||
|
||||
// Seed Clients
|
||||
var clients []models.Client
|
||||
for i := 0; i < 5; i++ {
|
||||
client := models.Client{
|
||||
Name: faker.Name(),
|
||||
Email: faker.Email(),
|
||||
Phone: faker.Phonenumber(),
|
||||
}
|
||||
db.Create(&client)
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
// Seed Users
|
||||
var users []models.User
|
||||
for i := 0; i < 20; i++ {
|
||||
user := models.User{
|
||||
Name: faker.Name(),
|
||||
Email: faker.Email(),
|
||||
Password: "hashedpassword123", // Replace with actual hash in production
|
||||
Role: roles[rand.Intn(len(roles))],
|
||||
Active: rand.Intn(2) == 1,
|
||||
Username: faker.Username(),
|
||||
ClientID: &clients[rand.Intn(len(clients))].ID,
|
||||
}
|
||||
db.Create(&user)
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
// Seed Tickets
|
||||
var tickets []models.Ticket
|
||||
for i := 0; i < 10; i++ {
|
||||
reporter := users[rand.Intn(len(users))]
|
||||
assignee := users[rand.Intn(len(users))]
|
||||
client := clients[rand.Intn(len(clients))]
|
||||
ticket := models.Ticket{
|
||||
Title: faker.Sentence(),
|
||||
Description: faker.Paragraph(),
|
||||
ReporterID: reporter.ID,
|
||||
AssigneeID: assignee.ID,
|
||||
ClientID: client.ID,
|
||||
Status: statuses[rand.Intn(len(statuses))],
|
||||
Priority: priorities[rand.Intn(len(priorities))],
|
||||
}
|
||||
db.Create(&ticket)
|
||||
tickets = append(tickets, ticket)
|
||||
}
|
||||
|
||||
// Seed Task Sections
|
||||
sectionTitles := []string{"Todo", "In Progress", "Done"}
|
||||
var taskSections []models.TaskSection
|
||||
for i, title := range sectionTitles {
|
||||
section := models.TaskSection{
|
||||
Title: title,
|
||||
Position: i,
|
||||
}
|
||||
db.Create(§ion)
|
||||
taskSections = append(taskSections, section)
|
||||
}
|
||||
|
||||
// Seed Tasks
|
||||
for i := 0; i < 15; i++ {
|
||||
section := taskSections[rand.Intn(len(taskSections))]
|
||||
task := models.Tasks{
|
||||
Title: faker.Sentence(),
|
||||
Description: faker.Paragraph(),
|
||||
Position: i,
|
||||
SectionID: section.ID,
|
||||
}
|
||||
|
||||
db.Create(&task)
|
||||
}
|
||||
|
||||
// Seed General/Group Chats
|
||||
for i := 0; i < 5; i++ {
|
||||
chatUsers := randomUsers(users, rand.Intn(5)+3) // 3-7 users
|
||||
chat := models.Chat{
|
||||
Name: faker.Word() + " Group",
|
||||
IsPrivate: false,
|
||||
Users: chatUsers,
|
||||
}
|
||||
db.Create(&chat)
|
||||
|
||||
// Seed messages for group chat
|
||||
for j := 0; j < rand.Intn(10)+5; j++ {
|
||||
sender := chatUsers[rand.Intn(len(chatUsers))]
|
||||
msg := models.ChatMessage{
|
||||
ChatID: chat.ID,
|
||||
SenderID: sender.ID,
|
||||
Content: faker.Sentence(),
|
||||
SentAt: time.Now().Add(time.Duration(-rand.Intn(1000)) * time.Minute),
|
||||
}
|
||||
db.Create(&msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed Ticket Chats
|
||||
for _, ticket := range tickets {
|
||||
// Reporter, Assignee, and 1-2 random watchers
|
||||
watchers := randomUsers(users, rand.Intn(2)+1)
|
||||
chatUsers := []models.User{
|
||||
findUserByID(users, ticket.ReporterID),
|
||||
findUserByID(users, ticket.AssigneeID),
|
||||
}
|
||||
chatUsers = append(chatUsers, watchers...)
|
||||
|
||||
chat := models.Chat{
|
||||
Name: "Ticket Chat",
|
||||
TicketID: &ticket.ID,
|
||||
IsPrivate: false,
|
||||
Users: chatUsers,
|
||||
}
|
||||
db.Create(&chat)
|
||||
|
||||
// Seed messages for ticket chat
|
||||
for j := 0; j < rand.Intn(10)+5; j++ {
|
||||
sender := chatUsers[rand.Intn(len(chatUsers))]
|
||||
msg := models.ChatMessage{
|
||||
ChatID: chat.ID,
|
||||
SenderID: sender.ID,
|
||||
Content: faker.Sentence(),
|
||||
SentAt: time.Now().Add(time.Duration(-rand.Intn(1000)) * time.Minute),
|
||||
}
|
||||
db.Create(&msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed Private 1:1 Chats
|
||||
for i := 0; i < 10; i++ {
|
||||
usersPair := randomUsers(users, 2)
|
||||
chat := models.Chat{
|
||||
Name: usersPair[0].Name + " & " + usersPair[1].Name,
|
||||
IsPrivate: true,
|
||||
Users: usersPair,
|
||||
}
|
||||
db.Create(&chat)
|
||||
|
||||
// Seed messages for private chat
|
||||
for j := 0; j < rand.Intn(10)+5; j++ {
|
||||
sender := usersPair[rand.Intn(2)]
|
||||
msg := models.ChatMessage{
|
||||
ChatID: chat.ID,
|
||||
SenderID: sender.ID,
|
||||
Content: faker.Sentence(),
|
||||
SentAt: time.Now().Add(time.Duration(-rand.Intn(1000)) * time.Minute),
|
||||
}
|
||||
db.Create(&msg)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Seeding complete!")
|
||||
}
|
||||
92
docker-compose.yml
Normal file
92
docker-compose.yml
Normal file
@@ -0,0 +1,92 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: prod
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT}:${PORT}"
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV}
|
||||
PORT: ${PORT}
|
||||
BLUEPRINT_DB_HOST: psql_bp
|
||||
BLUEPRINT_DB_PORT: 5432
|
||||
BLUEPRINT_DB_DATABASE: ${BLUEPRINT_DB_DATABASE}
|
||||
BLUEPRINT_DB_USERNAME: ${BLUEPRINT_DB_USERNAME}
|
||||
BLUEPRINT_DB_PASSWORD: ${BLUEPRINT_DB_PASSWORD}
|
||||
BLUEPRINT_DB_SCHEMA: ${BLUEPRINT_DB_SCHEMA}
|
||||
EMAIL_FROM: ${EMAIL_FROM}
|
||||
EMAIL_PASSWORD: ${EMAIL_PASSWORD}
|
||||
SMTP_HOST: ${SMTP_HOST}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
APP_URL: ${APP_URL}
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||
GOOGLE_CALLBACK_URL: ${GOOGLE_CALLBACK_URL}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
|
||||
GITHUB_CALLBACK_URL: ${GITHUB_CALLBACK_URL}
|
||||
depends_on:
|
||||
psql_bp:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- blueprint
|
||||
chat-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.chat
|
||||
ports:
|
||||
- "5000:5000"
|
||||
networks:
|
||||
- blueprint
|
||||
webrtc-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.webrtc
|
||||
ports:
|
||||
- "4000:4000"
|
||||
networks:
|
||||
- blueprint
|
||||
psql_bp:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${BLUEPRINT_DB_DATABASE}
|
||||
POSTGRES_USER: ${BLUEPRINT_DB_USERNAME}
|
||||
POSTGRES_PASSWORD: ${BLUEPRINT_DB_PASSWORD}
|
||||
ports:
|
||||
- "${BLUEPRINT_DB_PORT}:5432"
|
||||
volumes:
|
||||
- psql_volume_bp:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "sh -c 'pg_isready -U ${BLUEPRINT_DB_USERNAME} -d ${BLUEPRINT_DB_DATABASE}'"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- blueprint
|
||||
seed:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.seed
|
||||
depends_on:
|
||||
psql_bp:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- blueprint
|
||||
environment:
|
||||
BLUEPRINT_DB_HOST: psql_bp
|
||||
BLUEPRINT_DB_PORT: 5432
|
||||
BLUEPRINT_DB_DATABASE: ${BLUEPRINT_DB_DATABASE}
|
||||
BLUEPRINT_DB_USERNAME: ${BLUEPRINT_DB_USERNAME}
|
||||
BLUEPRINT_DB_PASSWORD: ${BLUEPRINT_DB_PASSWORD}
|
||||
BLUEPRINT_DB_SCHEMA: ${BLUEPRINT_DB_SCHEMA}
|
||||
command: ["./seed"]
|
||||
|
||||
volumes:
|
||||
psql_volume_bp:
|
||||
networks:
|
||||
blueprint:
|
||||
105
internal/chat_service/chat_service.go
Normal file
105
internal/chat_service/chat_service.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package chat_service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"OpsMastery.v5/internal/utils"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
)
|
||||
|
||||
// Message struct definition
|
||||
// This struct represents a chat message sent/received via WebSocket.
|
||||
// It contains the message content and the sender's identifier (email).
|
||||
type Message struct {
|
||||
Content string `json:"content"` // The actual message text
|
||||
Sender string `json:"sender"` // The sender's email or username
|
||||
// Add more fields as needed (e.g., timestamp, chatId, etc.)
|
||||
}
|
||||
|
||||
// clients is a map of active WebSocket connections.
|
||||
// The key is the pointer to the websocket.Conn, the value is a boolean (always true).
|
||||
// This allows us to keep track of all connected clients for broadcasting messages.
|
||||
var clients = make(map[*websocket.Conn]bool)
|
||||
|
||||
// broadcastMessage sends a given Message to all connected clients.
|
||||
// If a client connection fails, it is removed from the clients map.
|
||||
func broadcastMessage(msg Message) {
|
||||
for client := range clients {
|
||||
// Send the message as JSON to the client
|
||||
err := client.WriteJSON(msg)
|
||||
if err != nil {
|
||||
// If sending fails, print error, close connection, and remove client
|
||||
fmt.Println("Error sending message:", err)
|
||||
client.Close()
|
||||
delete(clients, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start initializes and runs the chat WebSocket server.
|
||||
func Start() {
|
||||
// Create a new Fiber app instance
|
||||
app := fiber.New()
|
||||
|
||||
// Serve static files from the ./public directory at /public
|
||||
app.Static("/public", "./public")
|
||||
|
||||
// Middleware for /ws route to validate JWT token before allowing WebSocket upgrade
|
||||
app.Use("/ws", func(c *fiber.Ctx) error {
|
||||
// Extract token from query parameters
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
// If no token, reject the request
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("Missing token")
|
||||
}
|
||||
// Validate the JWT token
|
||||
claims, err := utils.ValidateJWT(token, false)
|
||||
if err != nil {
|
||||
// If token is invalid, reject the request
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("Invalid token")
|
||||
}
|
||||
// Store the user's email in Fiber's context locals for later use
|
||||
c.Locals("userEmail", claims["email"])
|
||||
// Proceed to the next handler (WebSocket upgrade)
|
||||
return c.Next()
|
||||
})
|
||||
|
||||
// WebSocket endpoint for chat communication
|
||||
app.Get("/ws", websocket.New(func(c *websocket.Conn) {
|
||||
// Retrieve the user's email from context locals
|
||||
userEmail, _ := c.Locals("userEmail").(string)
|
||||
// Register the new client connection
|
||||
clients[c] = true
|
||||
// Ensure client is removed and connection closed when handler exits
|
||||
defer func() {
|
||||
delete(clients, c)
|
||||
c.Close()
|
||||
}()
|
||||
// Main loop: read messages from this client and broadcast to all clients
|
||||
for {
|
||||
var msg Message
|
||||
// Try to read a JSON message from the client
|
||||
err := c.ReadJSON(&msg)
|
||||
if err != nil {
|
||||
// If JSON parsing fails, try to read a raw message (e.g., plain text)
|
||||
_, rawMsg, readErr := c.ReadMessage()
|
||||
if readErr != nil {
|
||||
// If reading fails, log and break the loop (disconnect client)
|
||||
fmt.Println("WebSocket closed:", readErr)
|
||||
break
|
||||
}
|
||||
// Construct a Message from the raw message and set sender
|
||||
msg = Message{Content: string(rawMsg), Sender: userEmail}
|
||||
} else {
|
||||
// If JSON was parsed, set the sender field
|
||||
msg.Sender = userEmail
|
||||
}
|
||||
// Broadcast the received message to all connected clients
|
||||
broadcastMessage(msg)
|
||||
}
|
||||
}))
|
||||
|
||||
// Start the Fiber server on port 5000
|
||||
app.Listen(":5000")
|
||||
}
|
||||
70
internal/database/database.go
Normal file
70
internal/database/database.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"OpsMastery.v5/internal/models"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Global GORM DB instance
|
||||
// This variable holds the database connection and is shared across the app.
|
||||
var db *gorm.DB
|
||||
|
||||
// DB returns the global db instance
|
||||
// Use this function to get the database connection anywhere in your codebase.
|
||||
func DB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// Init initializes the global db instance and runs automigrate
|
||||
// This function sets up the database connection using environment variables,
|
||||
// and automatically migrates all models to ensure the schema is up to date.
|
||||
func Init() {
|
||||
// Load environment variables for database connection.
|
||||
// These should be set in your .env file or container environment.
|
||||
dbHost := os.Getenv("BLUEPRINT_DB_HOST") // Database host (e.g., psql_bp or localhost)
|
||||
dbUser := os.Getenv("BLUEPRINT_DB_USERNAME") // Database username
|
||||
dbPassword := os.Getenv("BLUEPRINT_DB_PASSWORD") // Database password
|
||||
dbName := os.Getenv("BLUEPRINT_DB_DATABASE") // Database name
|
||||
dbPort := os.Getenv("BLUEPRINT_DB_PORT") // Database port (usually 5432 for Postgres)
|
||||
dbSSLMode := "disable" // SSL mode for local/dev; can be set via env for prod
|
||||
dbSchema := os.Getenv("BLUEPRINT_DB_SCHEMA") // Database schema (e.g., public)
|
||||
|
||||
// Build the Data Source Name (DSN) string for Postgres connection.
|
||||
// This string contains all connection parameters.
|
||||
dsn := "host=" + dbHost +
|
||||
" user=" + dbUser +
|
||||
" password=" + dbPassword +
|
||||
" dbname=" + dbName +
|
||||
" port=" + dbPort +
|
||||
" sslmode=" + dbSSLMode +
|
||||
" search_path=" + dbSchema
|
||||
|
||||
// Attempt to open a connection to the database using GORM and the DSN.
|
||||
var err error
|
||||
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
// If connection fails, log the error and exit the application.
|
||||
log.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Automigrate all models.
|
||||
// This will create or update tables for User, Ticket, Client, Chat, and ChatMessage.
|
||||
// It ensures your database schema matches your Go models.
|
||||
if err := db.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Ticket{},
|
||||
&models.TaskSection{},
|
||||
&models.Tasks{},
|
||||
&models.Client{},
|
||||
&models.Chat{},
|
||||
&models.ChatMessage{},
|
||||
); err != nil {
|
||||
// If migration fails, log the error and exit the application.
|
||||
log.Fatalf("failed to auto migrate models: %v", err)
|
||||
}
|
||||
}
|
||||
100
internal/database/database_test.go
Normal file
100
internal/database/database_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package database
|
||||
|
||||
// import (
|
||||
// "context"
|
||||
// "log"
|
||||
// "testing"
|
||||
// "time"
|
||||
|
||||
// "github.com/testcontainers/testcontainers-go"
|
||||
// "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
// "github.com/testcontainers/testcontainers-go/wait"
|
||||
// )
|
||||
|
||||
// func mustStartPostgresContainer() (func(context.Context, ...testcontainers.TerminateOption) error, error) {
|
||||
// var (
|
||||
// dbName = "database"
|
||||
// dbPwd = "password"
|
||||
// dbUser = "user"
|
||||
// )
|
||||
|
||||
// dbContainer, err := postgres.Run(
|
||||
// context.Background(),
|
||||
// "postgres:latest",
|
||||
// postgres.WithDatabase(dbName),
|
||||
// postgres.WithUsername(dbUser),
|
||||
// postgres.WithPassword(dbPwd),
|
||||
// testcontainers.WithWaitStrategy(
|
||||
// wait.ForLog("database system is ready to accept connections").
|
||||
// WithOccurrence(2).
|
||||
// WithStartupTimeout(5*time.Second)),
|
||||
// )
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// database = dbName
|
||||
// password = dbPwd
|
||||
// username = dbUser
|
||||
|
||||
// dbHost, err := dbContainer.Host(context.Background())
|
||||
// if err != nil {
|
||||
// return dbContainer.Terminate, err
|
||||
// }
|
||||
|
||||
// dbPort, err := dbContainer.MappedPort(context.Background(), "5432/tcp")
|
||||
// if err != nil {
|
||||
// return dbContainer.Terminate, err
|
||||
// }
|
||||
|
||||
// host = dbHost
|
||||
// port = dbPort.Port()
|
||||
|
||||
// return dbContainer.Terminate, err
|
||||
// }
|
||||
|
||||
// func TestMain(m *testing.M) {
|
||||
// teardown, err := mustStartPostgresContainer()
|
||||
// if err != nil {
|
||||
// log.Fatalf("could not start postgres container: %v", err)
|
||||
// }
|
||||
|
||||
// m.Run()
|
||||
|
||||
// if teardown != nil && teardown(context.Background()) != nil {
|
||||
// log.Fatalf("could not teardown postgres container: %v", err)
|
||||
// }
|
||||
// }
|
||||
|
||||
// func TestNew(t *testing.T) {
|
||||
// srv := New()
|
||||
// if srv == nil {
|
||||
// t.Fatal("New() returned nil")
|
||||
// }
|
||||
// }
|
||||
|
||||
// func TestHealth(t *testing.T) {
|
||||
// srv := New()
|
||||
|
||||
// stats := srv.Health()
|
||||
|
||||
// if stats["status"] != "up" {
|
||||
// t.Fatalf("expected status to be up, got %s", stats["status"])
|
||||
// }
|
||||
|
||||
// if _, ok := stats["error"]; ok {
|
||||
// t.Fatalf("expected error not to be present")
|
||||
// }
|
||||
|
||||
// if stats["message"] != "It's healthy" {
|
||||
// t.Fatalf("expected message to be 'It's healthy', got %s", stats["message"])
|
||||
// }
|
||||
// }
|
||||
|
||||
// func TestClose(t *testing.T) {
|
||||
// srv := New()
|
||||
|
||||
// if srv.Close() != nil {
|
||||
// t.Fatalf("expected Close() to return nil")
|
||||
// }
|
||||
// }
|
||||
396
internal/handlers/authHandler.go
Normal file
396
internal/handlers/authHandler.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"OpsMastery.v5/internal/database"
|
||||
"OpsMastery.v5/internal/models"
|
||||
"OpsMastery.v5/internal/utils"
|
||||
"github.com/gofiber/adaptor/v2"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/markbates/goth/gothic"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func SignUp(c *fiber.Ctx) error {
|
||||
var user models.User
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
user.Email = strings.ToLower(input.Email)
|
||||
user.Password = input.Password
|
||||
user.Name = strings.ToLower(input.Name)
|
||||
user.Role = input.Role
|
||||
user.Username = strings.ToLower(input.Username)
|
||||
user.Active = false
|
||||
user.VerificationToken = utils.GenerateRandomToken()
|
||||
|
||||
storedHash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to hash password"})
|
||||
}
|
||||
user.Password = string(storedHash)
|
||||
|
||||
log.Printf("Hashed password for user %s: %s\n", user.Email, user.Password)
|
||||
|
||||
var existingUser models.User
|
||||
if err := database.DB().Where("email = ? OR username = ?", user.Email, user.Username).First(&existingUser).Error; err == nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "Email or username already exists"})
|
||||
}
|
||||
|
||||
if err := database.DB().Create(&user).Error; err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := utils.SendVerificationEmail(user.Email, user.VerificationToken); err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send verification email"})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||
"message": "Registration successful. Please check your email to verify your account.",
|
||||
})
|
||||
}
|
||||
|
||||
func SignIn(c *fiber.Ctx) error {
|
||||
var userInput struct {
|
||||
EmailOrUsername string `json:"emailOrUsername"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&userInput); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"})
|
||||
}
|
||||
|
||||
// Force to lowercase for case-insensitive match
|
||||
normalizedInput := strings.ToLower(userInput.EmailOrUsername)
|
||||
|
||||
var user models.User
|
||||
if err := database.DB().Where("LOWER(email) = ? OR LOWER(username) = ?", normalizedInput, normalizedInput).First(&user).Error; err != nil {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid email/username or password"})
|
||||
}
|
||||
|
||||
if !user.Active {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "Please verify your email before signing in",
|
||||
})
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(userInput.Password)); err != nil {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid email/username or password"})
|
||||
}
|
||||
|
||||
// Generate JWTs
|
||||
accessToken, refreshToken, err := utils.GenerateJWT(user)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "Could not generate tokens"})
|
||||
}
|
||||
|
||||
// Set Refresh Token in HttpOnly Cookie
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: refreshToken,
|
||||
Expires: time.Now().Add(7 * 24 * time.Hour),
|
||||
HTTPOnly: true,
|
||||
Secure: false,
|
||||
SameSite: "Strict",
|
||||
Path: "/api/v1/auth/refresh", // restrict usage
|
||||
})
|
||||
|
||||
// Return Access Token in response (frontend stores in memory)
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"message": "Sign in successful",
|
||||
"access_token": accessToken,
|
||||
"user": fiber.Map{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"username": user.Username,
|
||||
"address": user.Address,
|
||||
"phoneNumber": user.PhoneNumber,
|
||||
"active": user.Active,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func RefreshToken(c *fiber.Ctx) error {
|
||||
refreshToken := c.Cookies("refresh_token")
|
||||
if refreshToken == "" {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "Refresh token not found"})
|
||||
}
|
||||
|
||||
claims, err := utils.ValidateJWT(refreshToken, true)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid refresh token"})
|
||||
}
|
||||
|
||||
// Get user from claims
|
||||
var user models.User
|
||||
if err := database.DB().First(&user, claims["sub"]).Error; err != nil {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
accessToken, newRefreshToken, err := utils.GenerateJWT(user)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "Could not generate tokens"})
|
||||
}
|
||||
|
||||
// Rotate refresh token
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: newRefreshToken,
|
||||
Expires: time.Now().Add(7 * 24 * time.Hour),
|
||||
HTTPOnly: true,
|
||||
Secure: false,
|
||||
SameSite: "Strict",
|
||||
Path: "/api/auth/refresh",
|
||||
})
|
||||
|
||||
// Return new Access Token
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"access_token": accessToken,
|
||||
})
|
||||
}
|
||||
|
||||
func SignOut(c *fiber.Ctx) error {
|
||||
// Invalidate the refresh token cookie
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-1 * time.Hour),
|
||||
HTTPOnly: true,
|
||||
Secure: false,
|
||||
SameSite: "Strict",
|
||||
Path: "/api/auth/refresh",
|
||||
})
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Successfully signed out"})
|
||||
}
|
||||
|
||||
// func VerifyEmail(c *fiber.Ctx) error {
|
||||
// token := c.Params("token")
|
||||
|
||||
// var user models.User
|
||||
// if err := database.DB().Where("verification_token = ?", token).First(&user).Error; err != nil {
|
||||
// return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||
// "error": "Invalid verification token",
|
||||
// })
|
||||
// }
|
||||
|
||||
// user.Active = true
|
||||
// user.VerificationToken = "" // Clear the token after verification
|
||||
|
||||
// if err := database.DB().Save(&user).Error; err != nil {
|
||||
// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
// "error": "Failed to verify email",
|
||||
// })
|
||||
// }
|
||||
|
||||
// return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
// "message": "Email verified successfully",
|
||||
// })
|
||||
// }
|
||||
func VerifyEmail(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&body); err != nil || body.Token == "" {
|
||||
return c.Redirect("http://localhost:3000/auth/verification-failed", fiber.StatusSeeOther)
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB().Where("verification_token = ?", body.Token).First(&user).Error; err != nil {
|
||||
return c.Redirect("http://localhost:3000/auth/verification-failed", fiber.StatusSeeOther)
|
||||
}
|
||||
|
||||
user.Active = true
|
||||
user.VerificationToken = ""
|
||||
|
||||
if err := database.DB().Save(&user).Error; err != nil {
|
||||
return c.Redirect("http://localhost:3000/auth/verification-failed", fiber.StatusSeeOther)
|
||||
}
|
||||
|
||||
return c.Redirect("http://localhost:3000/auth/verification-success", fiber.StatusSeeOther)
|
||||
}
|
||||
|
||||
func RequestPasswordReset(c *fiber.Ctx) error {
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"})
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB().Where("email = ?", input.Email).First(&user).Error; err != nil {
|
||||
// Don't reveal if email exists or not
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"message": "If your email is registered, you will receive a password reset link",
|
||||
})
|
||||
}
|
||||
|
||||
resetToken := utils.GenerateRandomToken()
|
||||
user.ResetToken = resetToken
|
||||
user.ResetTokenExpiry = time.Now().Add(1 * time.Hour)
|
||||
|
||||
// Log the token being set
|
||||
log.Printf("Setting reset token for user %s: %s", user.Email, resetToken)
|
||||
|
||||
if err := database.DB().Save(&user).Error; err != nil {
|
||||
log.Printf("Error saving user with reset token: %v", err)
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to process password reset",
|
||||
})
|
||||
}
|
||||
|
||||
// Add this log right before SendPasswordResetEmail
|
||||
log.Printf("About to send reset email with token: %s", resetToken)
|
||||
|
||||
if err := utils.SendPasswordResetEmail(user.Email, resetToken); err != nil {
|
||||
log.Printf("Error sending reset email: %v", err)
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to send reset email",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"message": "If your email is registered, you will receive a password reset link",
|
||||
})
|
||||
}
|
||||
|
||||
func ResetPassword(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
ResetToken string `json:"reset_token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// Log the raw request body
|
||||
rawBody := string(c.Body())
|
||||
log.Printf("Received reset password request body: %s", rawBody)
|
||||
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
log.Printf("Error parsing request body: %v", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("Reset token: %s", body.ResetToken)
|
||||
|
||||
// Validate the token
|
||||
var user models.User
|
||||
if err := database.DB().Where("reset_token = ?", body.ResetToken).First(&user).Error; err != nil {
|
||||
log.Printf("No user found with reset token: %s, error: %v", body.ResetToken, err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Invalid reset token",
|
||||
})
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if user.ResetTokenExpiry.Before(time.Now()) {
|
||||
log.Printf("Token expired. Expiry: %v, Current time: %v", user.ResetTokenExpiry, time.Now())
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Reset token has expired",
|
||||
})
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to hash password",
|
||||
})
|
||||
}
|
||||
|
||||
// Update user's password and clear reset token
|
||||
user.Password = string(hashedPassword)
|
||||
user.ResetToken = ""
|
||||
user.ResetTokenExpiry = time.Time{}
|
||||
|
||||
if err := database.DB().Save(&user).Error; err != nil {
|
||||
log.Printf("Error saving user: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to update password",
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("Password successfully reset for user: %s", user.Email)
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Password successfully reset",
|
||||
})
|
||||
}
|
||||
|
||||
func validateResetToken(token string) (bool, error) {
|
||||
var user models.User
|
||||
if err := database.DB().Where("reset_token = ?", token).First(&user).Error; err != nil {
|
||||
log.Printf("No user found with reset token: %s, error: %v", token, err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if user.ResetTokenExpiry.Before(time.Now()) {
|
||||
log.Printf("Token expired. Expiry: %v, Current time: %v", user.ResetTokenExpiry, time.Now())
|
||||
return false, fmt.Errorf("reset token has expired")
|
||||
}
|
||||
|
||||
log.Printf("Token validated successfully for user: %s", user.Email)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Generic OAuth login handler
|
||||
func OAuthLogin(c *fiber.Ctx) error {
|
||||
provider := c.Params("provider")
|
||||
// Set provider in query for gothic
|
||||
req := c.Request()
|
||||
uri := req.URI()
|
||||
uri.SetQueryString("provider=" + provider)
|
||||
return adaptor.HTTPHandlerFunc(gothic.BeginAuthHandler)(c)
|
||||
}
|
||||
|
||||
func OAuthCallback(c *fiber.Ctx) error {
|
||||
provider := c.Params("provider")
|
||||
req := c.Request()
|
||||
req.URI().SetQueryString("provider=" + provider)
|
||||
|
||||
return adaptor.HTTPHandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := gothic.CompleteUserAuth(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user with this email already exists
|
||||
var existingUser models.User
|
||||
email := strings.ToLower(user.Email)
|
||||
if err := database.DB().Where("LOWER(email) = ?", email).First(&existingUser).Error; err == nil {
|
||||
// User exists, do not allow OAuth sign up
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "This email already exists, please sign in.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ...proceed with creating new user from OAuth profile...
|
||||
// (your existing logic here)
|
||||
json.NewEncoder(w).Encode(user)
|
||||
})(c)
|
||||
}
|
||||
224
internal/handlers/chatHandler.go
Normal file
224
internal/handlers/chatHandler.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// // filepath: /home/cody/OpsMastery.v5/internal/handlers/chatHandler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"OpsMastery.v5/internal/database"
|
||||
"OpsMastery.v5/internal/models"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
clients = make(map[*websocket.Conn]uint) // map connection to user ID
|
||||
broadcast = make(chan Message)
|
||||
mu sync.Mutex
|
||||
chatClients = make(map[uint]map[*websocket.Conn]bool) // chatID -> set of connections
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
SenderID uint `json:"sender_id"`
|
||||
RecipientID uint `json:"recipient_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type Chat struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TicketID *uint `json:"ticket_id,omitempty"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
Users []models.User `json:"users"`
|
||||
Messages []models.ChatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
// WebSocket chat endpoint
|
||||
func ChatWebSocket(c *fiber.Ctx) error {
|
||||
// Upgrade to WebSocket
|
||||
if websocket.IsWebSocketUpgrade(c) {
|
||||
return c.Next()
|
||||
}
|
||||
return fiber.ErrUpgradeRequired
|
||||
}
|
||||
|
||||
func HandleChat(c *websocket.Conn, claims map[string]interface{}) {
|
||||
senderID, _ := claims["userID"].(uint)
|
||||
|
||||
// Register connection
|
||||
if chatClients[0] == nil {
|
||||
chatClients[0] = make(map[*websocket.Conn]bool)
|
||||
}
|
||||
chatClients[0][c] = true
|
||||
defer func() {
|
||||
delete(chatClients[0], c)
|
||||
c.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
var msg struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ReadJSON(&msg); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Save message to DB
|
||||
chatMsg := models.ChatMessage{
|
||||
ChatID: 0,
|
||||
SenderID: senderID,
|
||||
Content: msg.Content,
|
||||
SentAt: time.Now(),
|
||||
}
|
||||
database.DB().Create(&chatMsg)
|
||||
|
||||
// Broadcast to all clients in the chat
|
||||
for client := range chatClients[0] {
|
||||
client.WriteJSON(chatMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast messages to all clients
|
||||
func StartBroadcast() {
|
||||
for {
|
||||
msg := <-broadcast
|
||||
mu.Lock()
|
||||
for conn := range clients {
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
fmt.Println("Error broadcasting:", err)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/chats?user1=username1&user2=username2
|
||||
func GetChatHistory(c *fiber.Ctx) error {
|
||||
user1 := c.Query("user1")
|
||||
user2 := c.Query("user2")
|
||||
var messages []models.ChatMessage
|
||||
database.DB().Where(
|
||||
"(sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?)",
|
||||
user1, user2, user2, user1,
|
||||
).Order("created_at asc").Find(&messages)
|
||||
return c.JSON(messages) // Returns [] if no messages exist
|
||||
}
|
||||
|
||||
// GET /api/v1/chat_partners?user_id=some_user_id
|
||||
func GetChatPartners(c *fiber.Ctx) error {
|
||||
userID := c.Query("user_id")
|
||||
var partners []models.User
|
||||
database.DB().Raw(`
|
||||
SELECT DISTINCT u.*
|
||||
FROM users u
|
||||
JOIN chat_messages cm
|
||||
ON (cm.sender_id = u.id OR cm.recipient_id = u.id)
|
||||
WHERE (cm.sender_id = ? OR cm.recipient_id = ?) AND u.id != ?
|
||||
`, userID, userID, userID).Scan(&partners)
|
||||
return c.JSON(partners)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/chats/delete?user1=username1&user2=username2
|
||||
func DeleteChatBetweenUsers(c *fiber.Ctx) error {
|
||||
user1 := c.Query("user1")
|
||||
user2 := c.Query("user2")
|
||||
result := database.DB().Where(
|
||||
"(sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?)",
|
||||
user1, user2, user2, user1,
|
||||
).Delete(&models.ChatMessage{})
|
||||
if result.Error != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": result.Error.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"deleted": result.RowsAffected})
|
||||
}
|
||||
|
||||
// Create a new chat (group or direct)
|
||||
func CreateChat(c *fiber.Ctx) error {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
UserIDs []uint `json:"user_ids"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
TicketID *uint `json:"ticket_id"`
|
||||
}
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"})
|
||||
}
|
||||
|
||||
chat := models.Chat{
|
||||
Name: input.Name,
|
||||
IsPrivate: input.IsPrivate,
|
||||
TicketID: input.TicketID,
|
||||
}
|
||||
|
||||
if err := database.DB().Create(&chat).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not create chat"})
|
||||
}
|
||||
|
||||
if len(input.UserIDs) > 0 {
|
||||
var users []models.User
|
||||
if err := database.DB().Where("id IN ?", input.UserIDs).Find(&users).Error; err == nil {
|
||||
database.DB().Model(&chat).Association("Users").Append(users)
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(chat)
|
||||
}
|
||||
|
||||
// Add users to an existing chat
|
||||
func AddUsersToChat(c *fiber.Ctx) error {
|
||||
chatID := c.Params("chatId")
|
||||
var input struct {
|
||||
UserIDs []uint `json:"user_ids"`
|
||||
}
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"})
|
||||
}
|
||||
var chat models.Chat
|
||||
if err := database.DB().First(&chat, chatID).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Chat not found"})
|
||||
}
|
||||
var users []models.User
|
||||
if err := database.DB().Where("id IN ?", input.UserIDs).Find(&users).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Users not found"})
|
||||
}
|
||||
database.DB().Model(&chat).Association("Users").Append(users)
|
||||
return c.JSON(fiber.Map{"added": len(users)})
|
||||
}
|
||||
|
||||
// Get all chats for a user (for sidebar)
|
||||
func GetChatsForUser(c *fiber.Ctx) error {
|
||||
userID := c.Params("userId")
|
||||
var chats []models.Chat
|
||||
if err := database.DB().Joins("JOIN chat_users ON chat_users.chat_id = chats.id").
|
||||
Where("chat_users.user_id = ?", userID).
|
||||
Preload("Users").
|
||||
Preload("Messages").
|
||||
Find(&chats).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not fetch chats"})
|
||||
}
|
||||
return c.JSON(chats)
|
||||
}
|
||||
|
||||
// Get all messages for a chat
|
||||
func GetChatMessages(c *fiber.Ctx) error {
|
||||
chatID := c.Params("chatId")
|
||||
var messages []models.ChatMessage
|
||||
if err := database.DB().Where("chat_id = ?", chatID).Order("sent_at asc").Find(&messages).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not fetch messages"})
|
||||
}
|
||||
return c.JSON(messages)
|
||||
}
|
||||
|
||||
// Delete a chat (and its messages)
|
||||
func DeleteChat(c *fiber.Ctx) error {
|
||||
chatID := c.Params("chatId")
|
||||
if err := database.DB().Where("chat_id = ?", chatID).Delete(&models.ChatMessage{}).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not delete messages"})
|
||||
}
|
||||
if err := database.DB().Delete(&models.Chat{}, chatID).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not delete chat"})
|
||||
}
|
||||
return c.JSON(fiber.Map{"deleted": chatID})
|
||||
}
|
||||
327
internal/handlers/taskHandler.go
Normal file
327
internal/handlers/taskHandler.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"OpsMastery.v5/internal/database"
|
||||
"OpsMastery.v5/internal/models"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type sectionPayload struct {
|
||||
Title string `json:"title"`
|
||||
TitleAlt string `json:"Title"`
|
||||
Position *int `json:"position"`
|
||||
PositionAlt *int `json:"Position"`
|
||||
}
|
||||
|
||||
type taskPayload struct {
|
||||
Title string `json:"title"`
|
||||
TitleAlt string `json:"Title"`
|
||||
Description string `json:"description"`
|
||||
DescriptionAlt string `json:"Description"`
|
||||
Position *int `json:"position"`
|
||||
PositionAlt *int `json:"Position"`
|
||||
SectionID *uint `json:"section_id"`
|
||||
SectionIDAlt *uint `json:"SectionID"`
|
||||
}
|
||||
|
||||
func (p sectionPayload) normalizedTitle() string {
|
||||
title := strings.TrimSpace(p.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(p.TitleAlt)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func (p sectionPayload) normalizedPosition() int {
|
||||
if p.Position != nil {
|
||||
return *p.Position
|
||||
}
|
||||
if p.PositionAlt != nil {
|
||||
return *p.PositionAlt
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (p taskPayload) normalizedTask() models.Tasks {
|
||||
title := strings.TrimSpace(p.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(p.TitleAlt)
|
||||
}
|
||||
|
||||
description := p.Description
|
||||
if description == "" {
|
||||
description = p.DescriptionAlt
|
||||
}
|
||||
|
||||
position := 0
|
||||
if p.Position != nil {
|
||||
position = *p.Position
|
||||
} else if p.PositionAlt != nil {
|
||||
position = *p.PositionAlt
|
||||
}
|
||||
|
||||
sectionID := uint(0)
|
||||
if p.SectionID != nil {
|
||||
sectionID = *p.SectionID
|
||||
} else if p.SectionIDAlt != nil {
|
||||
sectionID = *p.SectionIDAlt
|
||||
}
|
||||
|
||||
return models.Tasks{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Position: position,
|
||||
SectionID: sectionID,
|
||||
}
|
||||
}
|
||||
|
||||
func sectionExists(sectionID uint) (bool, error) {
|
||||
var count int64
|
||||
err := database.DB().Model(&models.TaskSection{}).Where("id = ?", sectionID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func CreateTaskSection(c *fiber.Ctx) error {
|
||||
var payload sectionPayload
|
||||
if err := c.BodyParser(&payload); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
section := models.TaskSection{
|
||||
Title: payload.normalizedTitle(),
|
||||
Position: payload.normalizedPosition(),
|
||||
}
|
||||
|
||||
if section.Title == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "title is required"})
|
||||
}
|
||||
|
||||
if err := database.DB().Create(§ion).Error; err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(http.StatusCreated).JSON(section)
|
||||
}
|
||||
|
||||
func ListTaskSections(c *fiber.Ctx) error {
|
||||
var sections []models.TaskSection
|
||||
err := database.DB().
|
||||
Preload("Tasks", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("position asc, id asc")
|
||||
}).
|
||||
Order("position asc, id asc").
|
||||
Find(§ions).Error
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(sections)
|
||||
}
|
||||
|
||||
func UpdateTaskSectionByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
sectionID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid section ID"})
|
||||
}
|
||||
|
||||
var payload sectionPayload
|
||||
if err := c.BodyParser(&payload); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if title := payload.normalizedTitle(); title != "" {
|
||||
updates["title"] = title
|
||||
}
|
||||
if payload.Position != nil || payload.PositionAlt != nil {
|
||||
updates["position"] = payload.normalizedPosition()
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "at least one field is required"})
|
||||
}
|
||||
|
||||
result := database.DB().Model(&models.TaskSection{}).Where("id = ?", sectionID).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()})
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "Section not found"})
|
||||
}
|
||||
|
||||
var section models.TaskSection
|
||||
if err := database.DB().First(§ion, sectionID).Error; err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(section)
|
||||
}
|
||||
|
||||
func DeleteTaskSectionByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
sectionID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid section ID"})
|
||||
}
|
||||
|
||||
result := database.DB().Unscoped().Delete(&models.TaskSection{}, sectionID)
|
||||
if result.RowsAffected == 0 {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Section not found"})
|
||||
}
|
||||
if result.Error != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Section deleted successfully"})
|
||||
}
|
||||
|
||||
func CreateTask(c *fiber.Ctx) error {
|
||||
var payload taskPayload
|
||||
if err := c.BodyParser(&payload); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
task := payload.normalizedTask()
|
||||
if task.Title == "" || task.SectionID == 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "title and section_id are required"})
|
||||
}
|
||||
|
||||
exists, err := sectionExists(task.SectionID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if !exists {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "section not found"})
|
||||
}
|
||||
|
||||
if err := database.DB().Create(&task).Error; err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(http.StatusCreated).JSON(task)
|
||||
}
|
||||
|
||||
func ListTasks(c *fiber.Ctx) error {
|
||||
var tasks []models.Tasks
|
||||
err := database.DB().
|
||||
Preload("Section").
|
||||
Order("position asc, id asc").
|
||||
Find(&tasks).Error
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(tasks)
|
||||
}
|
||||
|
||||
func GetTaskByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var task models.Tasks
|
||||
|
||||
taskID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid task ID"})
|
||||
}
|
||||
|
||||
if err := database.DB().Preload("Section").First(&task, taskID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Task not found"})
|
||||
}
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(task)
|
||||
}
|
||||
|
||||
func DeleteTaskByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
taskID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid task ID"})
|
||||
}
|
||||
|
||||
result := database.DB().Unscoped().Delete(&models.Tasks{}, taskID)
|
||||
if result.RowsAffected == 0 {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Task not found"})
|
||||
}
|
||||
if result.Error != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Task deleted successfully"})
|
||||
}
|
||||
|
||||
func UpdateTaskByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
taskID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid task ID"})
|
||||
}
|
||||
|
||||
var payload taskPayload
|
||||
if err := c.BodyParser(&payload); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
title := strings.TrimSpace(payload.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(payload.TitleAlt)
|
||||
}
|
||||
if title != "" {
|
||||
updates["title"] = title
|
||||
}
|
||||
|
||||
if payload.Description != "" || payload.DescriptionAlt != "" {
|
||||
description := payload.Description
|
||||
if description == "" {
|
||||
description = payload.DescriptionAlt
|
||||
}
|
||||
updates["description"] = description
|
||||
}
|
||||
|
||||
if payload.Position != nil {
|
||||
updates["position"] = *payload.Position
|
||||
} else if payload.PositionAlt != nil {
|
||||
updates["position"] = *payload.PositionAlt
|
||||
}
|
||||
|
||||
sectionID := uint(0)
|
||||
if payload.SectionID != nil {
|
||||
sectionID = *payload.SectionID
|
||||
} else if payload.SectionIDAlt != nil {
|
||||
sectionID = *payload.SectionIDAlt
|
||||
}
|
||||
if sectionID != 0 {
|
||||
exists, err := sectionExists(sectionID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if !exists {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "section not found"})
|
||||
}
|
||||
updates["section_id"] = sectionID
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "at least one field is required"})
|
||||
}
|
||||
|
||||
result := database.DB().Model(&models.Tasks{}).Where("id = ?", taskID).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()})
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "Task not found"})
|
||||
}
|
||||
|
||||
var task models.Tasks
|
||||
if err := database.DB().Preload("Section").First(&task, taskID).Error; err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(task)
|
||||
}
|
||||
84
internal/handlers/ticketHandler.go
Normal file
84
internal/handlers/ticketHandler.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"OpsMastery.v5/internal/database"
|
||||
"OpsMastery.v5/internal/models"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func CreateTicket(c *fiber.Ctx) error {
|
||||
var ticket models.Ticket
|
||||
if err := c.BodyParser(&ticket); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := database.DB().Create(&ticket).Error; err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(http.StatusCreated).JSON(ticket)
|
||||
}
|
||||
|
||||
func ListTickets(c *fiber.Ctx) error {
|
||||
var tickets []models.Ticket
|
||||
if err := database.DB().Find(&tickets).Error; err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(tickets)
|
||||
}
|
||||
|
||||
func GetTicketByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var ticket models.Ticket
|
||||
|
||||
ticketID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid ticket ID"})
|
||||
}
|
||||
|
||||
if err := database.DB().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Ticket not found"})
|
||||
}
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(ticket)
|
||||
}
|
||||
|
||||
func DeleteTicketByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
ticketID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid ticket ID"})
|
||||
}
|
||||
|
||||
result := database.DB().Unscoped().Delete(&models.Ticket{}, ticketID)
|
||||
if result.RowsAffected == 0 {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Ticket not found"})
|
||||
}
|
||||
if result.Error != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Ticket deleted successfully"})
|
||||
}
|
||||
|
||||
func UpdateTicketByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
ticketID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid ticket ID"})
|
||||
}
|
||||
|
||||
var ticket models.Ticket
|
||||
if err := c.BodyParser(&ticket); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := database.DB().Model(&models.Ticket{}).Where("id = ?", ticketID).Updates(ticket).Error; err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Ticket updated successfully"})
|
||||
}
|
||||
191
internal/handlers/userHandler.go
Normal file
191
internal/handlers/userHandler.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"OpsMastery.v5/internal/database"
|
||||
"OpsMastery.v5/internal/models"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func ListUsers(c *fiber.Ctx) error {
|
||||
var users []models.User
|
||||
if err := database.DB().Unscoped().Find(&users).Error; err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(users)
|
||||
}
|
||||
|
||||
func GetUserByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
var user models.User
|
||||
if err := database.DB().First(&user, id).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
func DeleteUserByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
userIDParsed, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"})
|
||||
}
|
||||
|
||||
result := database.DB().Unscoped().Delete(&models.User{}, userIDParsed)
|
||||
if result.RowsAffected == 0 {
|
||||
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
if result.Error != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()})
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{"message": "User deleted successfully"})
|
||||
}
|
||||
|
||||
func UpdateUserByID(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
var user models.User
|
||||
if err := database.DB().First(&user, id).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
|
||||
var input models.User
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"})
|
||||
}
|
||||
|
||||
// Only update fields if present in input
|
||||
if input.Name != "" {
|
||||
user.Name = input.Name
|
||||
}
|
||||
if input.Username != "" {
|
||||
user.Username = input.Username
|
||||
}
|
||||
if input.Address != "" {
|
||||
user.Address = input.Address
|
||||
}
|
||||
if input.PhoneNumber != "" {
|
||||
user.PhoneNumber = input.PhoneNumber
|
||||
}
|
||||
if input.Role != "" {
|
||||
user.Role = input.Role
|
||||
}
|
||||
if input.Email != "" {
|
||||
user.Email = input.Email
|
||||
}
|
||||
if input.Password != "" {
|
||||
user.Password = input.Password // Consider hashing here!
|
||||
}
|
||||
// ...handle other fields as needed...
|
||||
|
||||
if err := database.DB().Save(&user).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not update user"})
|
||||
}
|
||||
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
func SetUserRole(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var user models.User
|
||||
|
||||
if err := database.DB().First(&user, id).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid input"})
|
||||
}
|
||||
|
||||
user.Role = input.Role
|
||||
if err := database.DB().Save(&user).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not update user role"})
|
||||
}
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
func GetCurrentUser(c *fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User ID not found"})
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB().First(&user, userID).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
func UpdateCurrentUser(c *fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User ID not found"})
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB().First(&user, userID).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
|
||||
// Parse form fields using Fiber's API
|
||||
name := c.FormValue("name")
|
||||
email := c.FormValue("email")
|
||||
// Handle file upload (if needed)
|
||||
fileHeader, err := c.FormFile("avatar")
|
||||
if err == nil {
|
||||
// Process the uploaded file
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Failed to open uploaded file"})
|
||||
}
|
||||
defer file.Close()
|
||||
// ...handle file...
|
||||
}
|
||||
|
||||
// Update user fields
|
||||
user.Name = name
|
||||
user.Email = email
|
||||
|
||||
if err := database.DB().Save(&user).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Could not update user"})
|
||||
}
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
func GetUserProfilePhoto(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var user models.User
|
||||
if err := database.DB().First(&user, id).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
|
||||
}
|
||||
if len(user.ProfilePhoto) == 0 {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Profile photo not found"})
|
||||
}
|
||||
// You may want to detect the image type; here we default to jpeg
|
||||
c.Set("Content-Type", "image/jpeg")
|
||||
return c.Send(user.ProfilePhoto)
|
||||
}
|
||||
|
||||
func SearchUsers(c *fiber.Ctx) error {
|
||||
q := c.Query("q")
|
||||
if q == "" {
|
||||
return c.JSON([]models.User{})
|
||||
}
|
||||
normalized := strings.ToLower(q)
|
||||
var users []models.User
|
||||
database.DB().Debug().Where(
|
||||
"LOWER(username) LIKE ? OR LOWER(email) LIKE ? OR LOWER(name) LIKE ?",
|
||||
"%"+normalized+"%", "%"+normalized+"%", "%"+normalized+"%",
|
||||
).Find(&users)
|
||||
return c.JSON(users)
|
||||
}
|
||||
81
internal/middleware/jwtMiddleware.go
Normal file
81
internal/middleware/jwtMiddleware.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"OpsMastery.v5/internal/utils"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
var (
|
||||
AccessTokenSecret = []byte(os.Getenv("JWT_ACCESS_SECRET"))
|
||||
RefreshTokenSecret = []byte(os.Getenv("JWT_REFRESH_SECRET"))
|
||||
)
|
||||
|
||||
func ValidateAccessToken(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("access_token")
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := cookie.Value
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return AccessTokenSecret, nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), "claims", claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func JWTMiddleware(c *fiber.Ctx) error {
|
||||
authHeader := c.Get("Authorization")
|
||||
var token string
|
||||
|
||||
if authHeader != "" {
|
||||
// Extract the token from the "Bearer <token>" format
|
||||
tokenParts := strings.Split(authHeader, " ")
|
||||
if len(tokenParts) == 2 && tokenParts[0] == "Bearer" {
|
||||
token = tokenParts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// If no token in header, check cookie
|
||||
if token == "" {
|
||||
token = c.Cookies("access_token")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing token"})
|
||||
}
|
||||
|
||||
claims, err := utils.ValidateJWT(token, false)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid token"})
|
||||
}
|
||||
|
||||
c.Locals("userID", claims["sub"])
|
||||
c.Locals("userRole", claims["role"])
|
||||
c.Locals("userEmail", claims["email"])
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
36
internal/middleware/roleMiddleware.go
Normal file
36
internal/middleware/roleMiddleware.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func OnlyAdmin(db *gorm.DB, fn fiber.Handler) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
role, ok := c.Locals("userRole").(string)
|
||||
if !ok || role != "Admin" {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
|
||||
}
|
||||
return fn(c)
|
||||
}
|
||||
}
|
||||
|
||||
func OnlyModerator(db *gorm.DB, fn fiber.Handler) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
role, ok := c.Locals("userRole").(string)
|
||||
if !ok || (role != "Moderator" && role != "Admin") {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
|
||||
}
|
||||
return fn(c)
|
||||
}
|
||||
}
|
||||
|
||||
func OnlyUser(db *gorm.DB, fn fiber.Handler) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
role, ok := c.Locals("userRole").(string)
|
||||
if !ok || role != "User" {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
|
||||
}
|
||||
return fn(c)
|
||||
}
|
||||
}
|
||||
33
internal/models/chatModel.go
Normal file
33
internal/models/chatModel.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Chat struct {
|
||||
gorm.Model
|
||||
Name string
|
||||
|
||||
TicketID *uint
|
||||
Ticket *Ticket `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||
|
||||
IsPrivate bool `gorm:"default:false"`
|
||||
|
||||
Users []User `gorm:"many2many:chat_users;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
||||
Messages []ChatMessage `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
gorm.Model
|
||||
ChatID uint
|
||||
Chat Chat `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
||||
|
||||
SentAt time.Time
|
||||
|
||||
SenderID uint
|
||||
Sender User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||
|
||||
Content string `gorm:"not null"`
|
||||
}
|
||||
15
internal/models/clientModel.go
Normal file
15
internal/models/clientModel.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"not null"`
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
Phone string
|
||||
|
||||
Users []User
|
||||
Tickets []Ticket
|
||||
}
|
||||
21
internal/models/taskModel.go
Normal file
21
internal/models/taskModel.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TaskSection struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Position int `gorm:"not null;default:0" json:"position"`
|
||||
Tasks []Tasks `gorm:"foreignKey:SectionID;references:ID;constraint:OnDelete:CASCADE;" json:"tasks,omitempty"`
|
||||
}
|
||||
|
||||
type Tasks struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Description string `json:"description"`
|
||||
Position int `gorm:"not null;default:0" json:"position"`
|
||||
SectionID uint `gorm:"index" json:"section_id"`
|
||||
Section TaskSection `gorm:"foreignKey:SectionID;references:ID;constraint:OnDelete:CASCADE;" json:"section,omitempty"`
|
||||
}
|
||||
25
internal/models/ticketModel.go
Normal file
25
internal/models/ticketModel.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Ticket struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"not null"`
|
||||
Description string
|
||||
|
||||
ReporterID uint
|
||||
Reporter User `gorm:"foreignKey:ReporterID"`
|
||||
|
||||
AssigneeID uint
|
||||
Assignee User `gorm:"foreignKey:AssigneeID"`
|
||||
|
||||
ClientID uint
|
||||
Client Client
|
||||
|
||||
Status string `gorm:"default:'Open'"` // Add this line
|
||||
Priority string `gorm:"default:'Normal'"` // Add this line
|
||||
|
||||
Chats []Chat
|
||||
}
|
||||
26
internal/models/userModel.go
Normal file
26
internal/models/userModel.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
Password string `gorm:"not null"`
|
||||
Name string
|
||||
Role string `gorm:"default:User"`
|
||||
Active bool `gorm:"default:true"`
|
||||
Username string `gorm:"uniqueIndex"`
|
||||
Address string
|
||||
PhoneNumber string
|
||||
ProfilePhoto []byte `gorm:"type:bytea"`
|
||||
ClientID *uint
|
||||
Client *Client
|
||||
|
||||
VerificationToken string `json:"-"`
|
||||
ResetToken string `json:"-"`
|
||||
ResetTokenExpiry time.Time `json:"-"`
|
||||
}
|
||||
26
internal/oauth/oauth.go
Normal file
26
internal/oauth/oauth.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"github.com/markbates/goth/providers/github"
|
||||
"github.com/markbates/goth/providers/google"
|
||||
)
|
||||
|
||||
func InitProviders() {
|
||||
goth.UseProviders(
|
||||
google.New(
|
||||
os.Getenv("GOOGLE_CLIENT_ID"),
|
||||
os.Getenv("GOOGLE_CLIENT_SECRET"),
|
||||
os.Getenv("GOOGLE_CALLBACK_URL"),
|
||||
"email", "profile",
|
||||
),
|
||||
github.New(
|
||||
os.Getenv("GITHUB_CLIENT_ID"),
|
||||
os.Getenv("GITHUB_CLIENT_SECRET"),
|
||||
os.Getenv("GITHUB_CALLBACK_URL"),
|
||||
"user:email",
|
||||
),
|
||||
)
|
||||
}
|
||||
115
internal/server/routes.go
Normal file
115
internal/server/routes.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"OpsMastery.v5/internal/handlers"
|
||||
"OpsMastery.v5/internal/middleware"
|
||||
"OpsMastery.v5/internal/webrtc_service"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *FiberServer) RegisterFiberRoutes(db *gorm.DB) {
|
||||
api := s.Group("/api/v1")
|
||||
|
||||
// Public routes (no authentication required)
|
||||
api.Get("/", func(c *fiber.Ctx) error {
|
||||
return c.SendString("Server is running!")
|
||||
})
|
||||
// Authentication routes
|
||||
api.Post("/signup", handlers.SignUp)
|
||||
api.Post("/signin", handlers.SignIn)
|
||||
api.Get("/verify/:token", handlers.VerifyEmail)
|
||||
api.Post("/forgot-password", handlers.RequestPasswordReset)
|
||||
api.Post("/reset-password", handlers.ResetPassword)
|
||||
|
||||
// Public route to get a user's profile photo by ID
|
||||
api.Get("/users/:id/profile_photo", handlers.GetUserProfilePhoto)
|
||||
|
||||
// OAuth routes (move under /api/v1)
|
||||
api.Get("/auth/:provider", handlers.OAuthLogin)
|
||||
api.Get("/auth/:provider/callback", handlers.OAuthCallback)
|
||||
|
||||
// Protected routes (require authentication)
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.JWTMiddleware)
|
||||
|
||||
// Allow all authenticated users to list users
|
||||
protected.Get("/users", handlers.ListUsers)
|
||||
protected.Get("/users/search", handlers.SearchUsers)
|
||||
|
||||
// Admin routes
|
||||
protected.Delete("/users/:id", middleware.OnlyAdmin(db, handlers.DeleteUserByID))
|
||||
protected.Put("/users/:id/role", middleware.OnlyAdmin(db, handlers.SetUserRole))
|
||||
|
||||
// Moderator routes
|
||||
protected.Get("/users/:id", middleware.OnlyModerator(db, handlers.GetUserByID))
|
||||
protected.Put("/users/:id", middleware.OnlyModerator(db, handlers.UpdateUserByID))
|
||||
|
||||
// User routes
|
||||
protected.Get("/users/me", middleware.OnlyUser(db, handlers.GetCurrentUser))
|
||||
protected.Put("/users/me", middleware.OnlyUser(db, handlers.UpdateCurrentUser))
|
||||
|
||||
// Other protected routes
|
||||
protected.Post("/signout", handlers.SignOut)
|
||||
protected.Post("/auth/refresh", handlers.RefreshToken)
|
||||
|
||||
// Protected routes for tickets
|
||||
protected.Post("/ticket", handlers.CreateTicket)
|
||||
protected.Get("/tickets", handlers.ListTickets)
|
||||
protected.Get("/ticket/:id", handlers.GetTicketByID)
|
||||
protected.Put("/ticket/:id", handlers.UpdateTicketByID)
|
||||
protected.Delete("/ticket/:id", handlers.DeleteTicketByID)
|
||||
|
||||
// Protected routes for task sections
|
||||
protected.Post("/task-sections", handlers.CreateTaskSection)
|
||||
protected.Get("/task-sections", handlers.ListTaskSections)
|
||||
protected.Put("/task-section/:id", handlers.UpdateTaskSectionByID)
|
||||
protected.Delete("/task-section/:id", handlers.DeleteTaskSectionByID)
|
||||
|
||||
// Protected routes for tasks
|
||||
protected.Post("/tasks", handlers.CreateTask)
|
||||
protected.Get("/tasks", handlers.ListTasks)
|
||||
protected.Get("/task/:id", handlers.GetTaskByID)
|
||||
protected.Put("/task/:id", handlers.UpdateTaskByID)
|
||||
protected.Delete("/task/:id", handlers.DeleteTaskByID)
|
||||
|
||||
// Protected routes for chat
|
||||
protected.Get("/chats", handlers.GetChatHistory)
|
||||
protected.Post("/chats", handlers.CreateChat)
|
||||
protected.Post("/chats/:chatId/users", handlers.AddUsersToChat)
|
||||
protected.Get("/chats/user/:userId", handlers.GetChatsForUser)
|
||||
protected.Get("/chats/:chatId/messages", handlers.GetChatMessages)
|
||||
protected.Delete("/chats/:chatId", handlers.DeleteChat)
|
||||
|
||||
// WebRTC routes
|
||||
protected.Post("/webrtc/start", func(c *fiber.Ctx) error {
|
||||
go webrtc_service.StartSignalingServer()
|
||||
return c.JSON(fiber.Map{"status": "signaling server started"})
|
||||
})
|
||||
|
||||
// Chat WebSocket route
|
||||
// protected.Get("/chat/ws", websocket.New(func(c *websocket.Conn) {
|
||||
// token := c.Query("token")
|
||||
// if token == "" {
|
||||
// fmt.Println("WebSocket closed: missing token")
|
||||
// c.Close()
|
||||
// return
|
||||
// }
|
||||
// claims, err := utils.ValidateJWT(token, false)
|
||||
// if err != nil {
|
||||
// fmt.Println("WebSocket closed: invalid token:", err)
|
||||
// c.Close()
|
||||
// return
|
||||
// }
|
||||
// // Set user info as locals for use in HandleChat
|
||||
|
||||
// handlers.HandleChat(c, claims)
|
||||
// }))
|
||||
|
||||
api.Options("/chat/ws", func(c *fiber.Ctx) error {
|
||||
c.Set("Access-Control-Allow-Origin", "http://localhost:3000")
|
||||
c.Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
})
|
||||
}
|
||||
39
internal/server/routes_test.go
Normal file
39
internal/server/routes_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package server
|
||||
|
||||
// import (
|
||||
// "github.com/gofiber/fiber/v2"
|
||||
// "io"
|
||||
// "net/http"
|
||||
// "testing"
|
||||
// )
|
||||
|
||||
// func TestHandler(t *testing.T) {
|
||||
// // Create a Fiber app for testing
|
||||
// app := fiber.New()
|
||||
// // Inject the Fiber app into the server
|
||||
// s := &FiberServer{App: app}
|
||||
// // Define a route in the Fiber app
|
||||
// app.Get("/", s.HelloWorldHandler)
|
||||
// // Create a test HTTP request
|
||||
// req, err := http.NewRequest("GET", "/", nil)
|
||||
// if err != nil {
|
||||
// t.Fatalf("error creating request. Err: %v", err)
|
||||
// }
|
||||
// // Perform the request
|
||||
// resp, err := app.Test(req)
|
||||
// if err != nil {
|
||||
// t.Fatalf("error making request to server. Err: %v", err)
|
||||
// }
|
||||
// // Your test assertions...
|
||||
// if resp.StatusCode != http.StatusOK {
|
||||
// t.Errorf("expected status OK; got %v", resp.Status)
|
||||
// }
|
||||
// expected := "{\"message\":\"Hello World\"}"
|
||||
// body, err := io.ReadAll(resp.Body)
|
||||
// if err != nil {
|
||||
// t.Fatalf("error reading response body. Err: %v", err)
|
||||
// }
|
||||
// if expected != string(body) {
|
||||
// t.Errorf("expected response body to be %v; got %v", expected, string(body))
|
||||
// }
|
||||
// }
|
||||
30
internal/server/server.go
Normal file
30
internal/server/server.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
)
|
||||
|
||||
type FiberServer struct {
|
||||
*fiber.App
|
||||
}
|
||||
|
||||
func New() *FiberServer {
|
||||
server := &FiberServer{
|
||||
App: fiber.New(fiber.Config{
|
||||
ServerHeader: "OpsMastery.v5",
|
||||
AppName: "OpsMastery.v5",
|
||||
}),
|
||||
}
|
||||
|
||||
// Correct CORS origin to allow your frontend domain
|
||||
server.App.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "http://localhost:3000",
|
||||
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS,PATCH",
|
||||
AllowHeaders: "Accept,Authorization,Content-Type",
|
||||
AllowCredentials: true, // allow credentials for cookies/auth
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
return server
|
||||
}
|
||||
85
internal/utils/emailUtils.go
Normal file
85
internal/utils/emailUtils.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/smtp"
|
||||
"os"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
func SendVerificationEmail(to string, token string) error {
|
||||
from := os.Getenv("EMAIL_FROM")
|
||||
password := os.Getenv("EMAIL_PASSWORD")
|
||||
smtpHost := os.Getenv("SMTP_HOST")
|
||||
smtpPort := os.Getenv("SMTP_PORT")
|
||||
|
||||
verificationLink := fmt.Sprintf("%s/api/v1/verify/%s", os.Getenv("APP_URL"), token)
|
||||
|
||||
subject := "Verify Your Email"
|
||||
body := fmt.Sprintf(`
|
||||
<html>
|
||||
<body>
|
||||
<h2>Welcome to OpsMastery!</h2>
|
||||
<p>Please verify your email address by clicking the link below:</p>
|
||||
<a href="%s">Verify Email</a>
|
||||
<p>If you didn't create this account, please ignore this email.</p>
|
||||
</body>
|
||||
</html>
|
||||
`, verificationLink)
|
||||
|
||||
message := fmt.Sprintf("To: %s\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
"MIME-Version: 1.0\r\n"+
|
||||
"Content-Type: text/html; charset=UTF-8\r\n"+
|
||||
"\r\n"+
|
||||
"%s\r\n", to, subject, body)
|
||||
|
||||
auth := smtp.PlainAuth("", from, password, smtpHost)
|
||||
addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort)
|
||||
|
||||
err := smtp.SendMail(addr, auth, from, []string{to}, []byte(message))
|
||||
if err != nil {
|
||||
log.Printf("SMTP error: %v", err) // Add this line for more details
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func SendPasswordResetEmail(to string, token string) error {
|
||||
from := os.Getenv("EMAIL_FROM")
|
||||
password := os.Getenv("EMAIL_PASSWORD")
|
||||
smtpHost := os.Getenv("SMTP_HOST")
|
||||
smtpPort := os.Getenv("SMTP_PORT")
|
||||
|
||||
//resetLink := fmt.Sprintf("%s/auth/reset-password?reset_token=%s", os.Getenv("FRONTEND_URL"), token)
|
||||
|
||||
resetLink := fmt.Sprintf("http://localhost:3000/auth/reset-password?reset_token=%s", token)
|
||||
|
||||
log.Printf("Generated reset link: %s", resetLink)
|
||||
|
||||
subject := "Reset Your Password"
|
||||
body := fmt.Sprintf(`
|
||||
<html>
|
||||
<body>
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>Click the link below to reset your password:</p>
|
||||
<a href="%s">Reset Password</a>
|
||||
<p>If you didn't request this, please ignore this email.</p>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
</body>
|
||||
</html>
|
||||
`, resetLink)
|
||||
|
||||
message := fmt.Sprintf("To: %s\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
"MIME-Version: 1.0\r\n"+
|
||||
"Content-Type: text/html; charset=UTF-8\r\n"+
|
||||
"\r\n"+
|
||||
"%s\r\n", to, subject, body)
|
||||
|
||||
auth := smtp.PlainAuth("", from, password, smtpHost)
|
||||
addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort)
|
||||
|
||||
return smtp.SendMail(addr, auth, from, []string{to}, []byte(message))
|
||||
}
|
||||
63
internal/utils/jwtUtils.go
Normal file
63
internal/utils/jwtUtils.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"OpsMastery.v5/internal/models"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
var (
|
||||
accessTokenSecret = []byte(os.Getenv("JWT_ACCESS_SECRET"))
|
||||
refreshTokenSecret = []byte(os.Getenv("JWT_REFRESH_SECRET"))
|
||||
)
|
||||
|
||||
func GenerateJWT(user models.User) (string, string, error) {
|
||||
accessClaims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"exp": time.Now().Add(time.Minute * 15).Unix(),
|
||||
}
|
||||
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
signedAccessToken, err := accessToken.SignedString(accessTokenSecret)
|
||||
if err != nil {
|
||||
log.Println("Error generating access token:", err)
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
refreshClaims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(),
|
||||
}
|
||||
|
||||
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||
signedRefreshToken, err := refreshToken.SignedString(refreshTokenSecret)
|
||||
if err != nil {
|
||||
log.Println("Error generating refresh token:", err)
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return signedAccessToken, signedRefreshToken, nil
|
||||
}
|
||||
|
||||
func ValidateJWT(tokenString string, isRefreshToken bool) (jwt.MapClaims, error) {
|
||||
secret := accessTokenSecret
|
||||
if isRefreshToken {
|
||||
secret = refreshTokenSecret
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.NewValidationError("invalid signing method", jwt.ValidationErrorClaimsInvalid)
|
||||
}
|
||||
return secret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, err
|
||||
}
|
||||
return token.Claims.(jwt.MapClaims), nil
|
||||
}
|
||||
15
internal/utils/tokenUtils.go
Normal file
15
internal/utils/tokenUtils.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func GenerateRandomToken() string {
|
||||
bytes := make([]byte, 16) // 16 bytes = 128 bits
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
panic("Failed to generate random token")
|
||||
}
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
57
internal/webrtc_service/signaling_server.go
Normal file
57
internal/webrtc_service/signaling_server.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package webrtc_service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"OpsMastery.v5/internal/utils"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
func StartSignalingServer() {
|
||||
// Create a new peer connection configuration
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a new peer connection
|
||||
peerConnection, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to create peer connection:", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer peerConnection.Close()
|
||||
|
||||
app := fiber.New()
|
||||
|
||||
app.Use("/ws", func(c *fiber.Ctx) error {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("Missing token")
|
||||
}
|
||||
claims, err := utils.ValidateJWT(token, false)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("Invalid token")
|
||||
}
|
||||
c.Locals("userEmail", claims["email"])
|
||||
return c.Next()
|
||||
})
|
||||
|
||||
app.Get("/ws", websocket.New(func(c *websocket.Conn) {
|
||||
userEmail, _ := c.Locals("userEmail").(string)
|
||||
fmt.Println("WebRTC signaling WebSocket connected for user:", userEmail)
|
||||
// TODO: Implement signaling logic here
|
||||
// You can use userEmail for user identification
|
||||
}))
|
||||
|
||||
app.Listen(":4000")
|
||||
|
||||
// Set up signaling handlers (to be implemented)
|
||||
fmt.Println("Signaling server started")
|
||||
}
|
||||
Reference in New Issue
Block a user