diff --git a/.gitignore b/.gitignore index 698d967..14d243e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ -Software/.env node_modules secrets.h sketches/secrets.h + +.env +.vscode \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5a70b1e --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,12 @@ +# backend/.env — local development only +# Copy this to .env and fill in values. Never commit .env. + +MONGO_URI=mongodb+srv://:@.mongodb.net/terradetect +DB_NAME=terradetect +SECRET_KEY=generate-with-openssl-rand-hex-32 +WEATHER_API_KEY=your-weatherapi-com-key +PORT=8080 + +# Paths to ONNX model files (relative to backend/ directory) +CROP_MODEL_PATH=../ml/crop-model.onnx +FERTILIZER_MODEL_PATH=../ml/fertilizer-model.onnx \ No newline at end of file diff --git a/backend/config/config.go b/backend/config/config.go index 47c9b73..130e1e5 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -13,6 +13,8 @@ type Config struct { SecretKey string WeatherAPIKey string Port string + CropModelPath string + FertilizerModelPath string } func Load() *Config { @@ -24,6 +26,8 @@ func Load() *Config { SecretKey: mustGet("SECRET_KEY"), WeatherAPIKey: mustGet("WEATHER_API_KEY"), Port: getOrDefault("PORT", "8080"), + CropModelPath: getOrDefault("CROP_MODEL_PATH", "../ml/crop-model.onnx"), + FertilizerModelPath: getOrDefault("FERTILIZER_MODEL_PATH", "../ml/fertilizer-model.onnx"), } return cfg diff --git a/backend/db/mongo.go b/backend/db/mongo.go index e519b64..969b86b 100644 --- a/backend/db/mongo.go +++ b/backend/db/mongo.go @@ -37,15 +37,23 @@ func Connect(uri, dbName string) (*Database, error) { TokenDenyList: d.Collection("token_deny_list"), } - database.SensorData.Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "device_id", Value: 1}, {Key: "timestamp", Value: -1}}, + _, _ = database.SensorData.Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{ + {Key: "device_id", Value: 1}, + {Key: "timestamp", Value: -1}, + }, }) ttl := int32(0) - database.TokenDenyList.Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "expires_at", Value: 1}}, + _, _ = database.TokenDenyList.Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "expires_at", Value: 1}}, Options: &options.IndexOptions{ExpireAfterSeconds: &ttl}, }) + _, _ = database.Users.Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "username", Value: 1}}, + Options: options.Index().SetUnique(true), + }) + return database, nil } diff --git a/backend/go.mod b/backend/go.mod index d8b6a09..78259f1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,11 +3,14 @@ module github.com/gagan-devv/terradetect/backend go 1.26.1 require ( + github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.12.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 github.com/ulule/limiter/v3 v3.11.2 + github.com/yalue/onnxruntime_go v1.27.0 go.mongodb.org/mongo-driver v1.17.9 + golang.org/x/crypto v0.49.0 ) require ( @@ -43,7 +46,6 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 6e8ec87..07faa17 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= @@ -86,6 +88,8 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U= +github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go index 28ae6f5..0c391c7 100644 --- a/backend/handlers/auth.go +++ b/backend/handlers/auth.go @@ -1 +1,314 @@ -package handlers \ No newline at end of file +package handlers + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "time" + + "github.com/gagan-devv/terradetect/backend/config" + "github.com/gagan-devv/terradetect/backend/db" + "github.com/gagan-devv/terradetect/backend/models" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "golang.org/x/crypto/bcrypt" +) + +type AuthHandler struct { + db *db.Database + cfg *config.Config +} + +func NewAuthHandler(database *db.Database, cfg *config.Config) *AuthHandler { + return &AuthHandler{ + db: database, + cfg: cfg, + } +} + +type registerRequest struct { + Username string `json:"username" binding:"required,min=3,max=32"` + Password string `json:"password" binding:"required,min=8"` + DeviceID string `json:"device_id" binding:"required,len=6"` +} + +func (h *AuthHandler) Register(c *gin.Context) { + var req registerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()}, + }) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var device models.Device + err := h.db.Devices.FindOne(ctx, bson.M{ + "device_id": req.DeviceID, + "registered": false, + }).Decode(&device) + + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": gin.H{ + "code": "DEVICE_NOT_REGISTERED", + "message": "Invalid or already registered device ID.", + }, + }) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "code": "INTERNAL_ERROR", + "message": "Failed to hash password.", + }, + }) + return + } + + _, err = h.db.Users.InsertOne(ctx, bson.M{ + "username": req.Username, + "password_hash": string(hash), + "device_id": req.DeviceID, + "created_at": primitive.NewDateTimeFromTime(time.Now()), + }) + + if err != nil { + c.JSON(http.StatusConflict, gin.H{ + "error": gin.H{ + "code": "VALIDATION_ERROR", + "message": "Username already taken.", + }, + }) + return + } + + _, _ = h.db.Devices.UpdateOne(ctx, + bson.M{"device_id": req.DeviceID}, + bson.M{"$set": bson.M{"registered": true}}, + ) + + c.JSON(http.StatusCreated, gin.H{ + "username": req.Username, + "device_id": req.DeviceID, + "api_key": device.APIKey, + }) +} + +type loginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + DeviceID string `json:"device_id" binding:"required"` +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": gin.H{ + "code": "VALIDATION_ERROR", + "message": err.Error(), + }, + }) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var user models.User + + err := h.db.Users.FindOne(ctx, bson.M{ + "username": req.Username, + "device_id": req.DeviceID, + }).Decode(&user) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "code": "INVALID_CREDENTIALS", + "message": "Invalid username, password, or device ID.", + }, + }) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "code": "INVALID_CREDENTIALS", + "message": "Invalid username, password, or device ID.", + }, + }) + return + } + + accessToken, err := h.generateToken(user.Username, user.DeviceID, "access", 30*24*time.Hour) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "code": "INTERNAL_ERROR", + "message": "Failed to generate token.", + }, + }) + return + } + + refreshToken, err := h.generateToken(user.Username, user.DeviceID, "refresh", 30*24*time.Hour) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "code": "INTERNAL_ERROR", + "message": "Failed to generate token.", + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "refresh_token": refreshToken, + "expires_in": 900, + "token_type": "Bearer", + "user": gin.H{ + "username": user.Username, + "device_id": user.DeviceID, + }, + }) +} + +type refreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +func (h *AuthHandler) Refresh(c *gin.Context) { + var req refreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": gin.H{ + "code": "VALIDATION_ERROR", + "message": err.Error(), + }, + }) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(req.RefreshToken))) + count, _ := h.db.TokenDenyList.CountDocuments(ctx, bson.M{"token_hash": tokenHash}) + + if count > 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "code": "UNAUTHORIZED", + "message": "Token has been revoked.", + }, + }) + return + } + + token, err := jwt.Parse(req.RefreshToken, func(t *jwt.Token) (any, error) { + return []byte(h.cfg.SecretKey), nil + }) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "code": "UNAUTHORIZED", + "message": "Invalid or expired refresh token.", + }, + }) + return + } + + claims := token.Claims.(jwt.MapClaims) + if claims["token"] != "refresh" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "code": "UNAUTHORIZED", + "message": "Not a refresh token.", + }, + }) + return + } + + username := claims["username"].(string) + deviceID := claims["device_id"].(string) + + accessToken, err := h.generateToken(username, deviceID, "access", 15*time.Minute) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "code": "INTERNAL_ERROR", + "message": "Failed to generate token.", + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "expires_in": 900, + "token_type": "Bearer", + }) +} + +type logoutRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +func (h *AuthHandler) Logout(c *gin.Context) { + var req logoutRequest + _ = c.ShouldBindJSON(&req) + + if req.RefreshToken != "" { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Parse to get expiry for TTL + token, err := jwt.Parse(req.RefreshToken, + func(t *jwt.Token) (any, error) { return []byte(h.cfg.SecretKey), nil }, + jwt.WithValidMethods([]string{"HS256"}), + ) + + var expiresAt time.Time + if err == nil && token.Valid { + claims := token.Claims.(jwt.MapClaims) + if exp, ok := claims["exp"].(float64); ok { + expiresAt = time.Unix(int64(exp), 0) + } + } else { + expiresAt = time.Now().Add(30 * 24 * time.Hour) + } + + tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(req.RefreshToken))) + _, _ = h.db.TokenDenyList.InsertOne(ctx, bson.M{ + "token_hash": tokenHash, + "expires_at": primitive.NewDateTimeFromTime(expiresAt), + }) + } + + c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully."}) +} + +func (h *AuthHandler) generateToken(username, deviceID, tokenType string, duration time.Duration) (string, error) { + claims := jwt.MapClaims{ + "username": username, + "device_id": deviceID, + "token_type": tokenType, + "exp": time.Now().Add(duration).Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + return token.SignedString([]byte(h.cfg.SecretKey)) +} diff --git a/backend/handlers/device.go b/backend/handlers/device.go index 28ae6f5..f1912c2 100644 --- a/backend/handlers/device.go +++ b/backend/handlers/device.go @@ -1 +1,56 @@ -package handlers \ No newline at end of file +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/gagan-devv/terradetect/backend/db" + "github.com/gagan-devv/terradetect/backend/models" + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) + +type DeviceHandler struct { + db *db.Database +} + +func NewDeviceHandler(database *db.Database) *DeviceHandler { + return &DeviceHandler{ + db: database, + } +} + +type checkDeviceRequest struct { + DeviceID string `json:"device_id" binding:"required"` +} + +func (h *DeviceHandler) Check(c *gin.Context) { + var req checkDeviceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": gin.H{ + "code": "VALIDATION_ERROR", + "message": err.Error(), + }, + }) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second) + defer cancel() + + var device models.Device + err := h.db.Devices.FindOne(ctx, bson.M{"device_id": req.DeviceID}).Decode(&device) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "code": "NOT_FOUND", + "message": "Device ID not found.", + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{"registered": device.Registered}) +} \ No newline at end of file diff --git a/backend/handlers/predict.go b/backend/handlers/predict.go index 28ae6f5..0ec8eff 100644 --- a/backend/handlers/predict.go +++ b/backend/handlers/predict.go @@ -1 +1,393 @@ -package handlers \ No newline at end of file +package handlers + +import ( + "context" + "fmt" + "math" + "net/http" + "strings" + "time" + + "github.com/gagan-devv/terradetect/backend/db" + "github.com/gagan-devv/terradetect/backend/inference" + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type PredictHandler struct { + db *db.Database + onnx *inference.Models +} + +func NewPredictHandler(database *db.Database, onnx *inference.Models) *PredictHandler { + return &PredictHandler{ + db: database, + onnx: onnx, + } +} + +type predictRequest struct { + Source string `json:"source"` + N float64 `json:"N"` + P float64 `json:"P"` + K float64 `json:"K"` + Temp float64 `json:"temperature"` + Humidity float64 `json:"humidity"` + PH float64 `json:"ph"` + Rainfall float64 `json:"rainfall"` + Moisture float64 `json:"moisture"` + SoilType string `json:"soil_type"` + CropName string `json:"crop_name"` +} + +func (h *PredictHandler) resolveFeatures(c *gin.Context, req *predictRequest) bool { + if req.Source != "sensor" { + return true + } + + deviceID, _ := c.Get("device_id") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var doc struct { + Temperature float64 `bson:"temperature"` + Humidity float64 `bson:"humidity"` + PH float64 `bson:"ph"` + N float64 `bson:"N"` + P float64 `bson:"P"` + K float64 `bson:"K"` + Rainfall float64 `bson:"rainfall"` + Timestamp primitive.DateTime `bson:"timestamp"` + } + err := h.db.SensorData.FindOne(ctx, + bson.M{"device_id": deviceID}, + options.FindOne().SetSort(bson.D{{Key: "timestamp", Value: -1}}), + ).Decode(&doc) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "code": "NOT_FOUND", + "message": "No sensor data for your device.", + }, + }) + return false + } + req.N = doc.N + req.P = doc.P + req.K = doc.K + req.Temp = doc.Temperature + req.Humidity = doc.Humidity + req.PH = doc.PH + req.Rainfall = doc.Rainfall + return true +} + +func (h *PredictHandler) Crop(c *gin.Context) { + var req predictRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": gin.H{ + "code": "VALIDATION_ERROR", + "message": err.Error(), + }, + }) + return + } + + if !h.resolveFeatures(c, &req) { + return + } + + features := []float32{ + float32(req.N), float32(req.P), float32(req.K), + float32(req.Temp), float32(req.Humidity), float32(req.PH), + float32(req.Rainfall), + } + + crop, confidence, err := h.onnx.PredictCrop(features) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "code": "INTERNAL_ERROR", + "message": "Crop mdoel inference failed.", + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "recommended_crop": crop, + "confidence": math.Round(confidence*100) / 100, + }) +} + +var idealValues = map[string][7]float64{ + "rice": {80, 40, 40, 23, 82, 6.5, 236}, + "maize": {78, 48, 20, 22, 65, 6.0, 60}, + "chickpea": {40, 67, 79, 18, 16, 7.2, 80}, + "kidneybeans": {20, 67, 20, 19, 21, 5.7, 105}, + "pigeonpeas": {20, 67, 20, 27, 48, 5.8, 149}, + "mothbeans": {21, 48, 20, 28, 53, 6.9, 53}, + "mungbean": {20, 47, 20, 28, 85, 6.7, 48}, + "blackgram": {40, 67, 19, 30, 65, 7.0, 67}, + "lentil": {18, 68, 19, 24, 64, 6.9, 45}, + "pomegranate": {18, 18, 40, 21, 90, 6.2, 107}, + "banana": {100, 82, 50, 27, 80, 5.9, 105}, + "mango": {20, 27, 30, 31, 50, 5.7, 95}, + "grapes": {23, 132, 200, 24, 81, 6.0, 70}, + "watermelon": {99, 59, 50, 25, 85, 6.5, 50}, + "muskmelon": {100, 17, 50, 28, 92, 6.3, 25}, + "apple": {21, 134, 199, 22, 92, 5.9, 112}, + "orange": {20, 10, 10, 22, 92, 7.0, 110}, + "papaya": {50, 59, 50, 33, 92, 6.7, 143}, + "coconut": {21, 5, 30, 27, 94, 5.9, 142}, + "cotton": {118, 46, 20, 25, 79, 6.9, 80}, + "jute": {78, 46, 40, 24, 79, 6.7, 174}, + "coffee": {101, 28, 29, 25, 58, 6.8, 158}, + "wheat": {60, 30, 50, 20, 82, 6.5, 75}, +} + +var paramNames = [7]string{ + "Nitrogen (N)", "Phosphorus (P)", "Potassium (K)", + "Temperature", "Humidity", "pH", "Rainfall", +} + +type tableRow struct { + Parameter string `json:"parameter"` + Recommended float64 `json:"recommended"` + Observed float64 `json:"observed"` + Status string `json:"status"` + Remarks string `json:"remarks"` +} + +func (h *PredictHandler) Suitability(c *gin.Context) { + var req predictRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "code": "INTERNAL_ERROR", + "message": err.Error(), + }, + }) + return + } + + if req.CropName == "" { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": gin.H{ + "code": "VALIDATION_ERROR", + "message": "crop_name is required.", + }, + }) + return + } + + cropKey := strings.ToLower(strings.ReplaceAll(req.CropName, " ", "")) + ideal, ok := idealValues[cropKey] + if !ok { + c.JSON(http.StatusBadRequest, gin.H{ + "error": gin.H{ + "code": "NOT_FOUND", + "message": "Crop not found in dataset.", + }, + }) + return + } + + observed := [7]float64{ + req.N, req.P, req.K, + req.Temp, req.Humidity, req.PH, req.Rainfall, + } + + var totalDeviation float64 + for i := 0; i < 7; i++ { + maxDev := ideal[i] * 0.2 + if maxDev == 0 { + maxDev = 1 + } + totalDeviation += math.Abs(observed[i]-ideal[i]) / maxDev + } + score := math.Abs(100 - (totalDeviation * 100 / 7)) + score = math.Mod(score, 100) + score = math.Round(score*100) / 100 + + fertAdvice := map[string]string{ + "Nitrogen (N)": "Apply nitrogen-rich fertilizers like urea or ammonium sulfate.", + "Phosphorus (P)": "Use phosphorus fertilizers such as bone meal or superphosphate.", + "Potassium (K)": "Add potassium-based fertilizers like potash or wood ash.", + "Temperature": "Use greenhouse techniques or choose planting times strategically.", + "Humidity": "Implement irrigation systems or use mulching techniques.", + "pH": "Add lime to increase pH or sulfur to decrease pH.", + "Rainfall": "Use drip irrigation or rainwater harvesting techniques.", + } + + var table []tableRow + var recommendations []string + + for i := 0; i < 7; i++ { + row := tableRow{ + Parameter: paramNames[i], + Recommended: math.Round(ideal[i]*100) / 100, + Observed: math.Round(observed[i]*100) / 100, + + Status: "optimal", + Remarks: "Optimial", + } + low := ideal[i] * 0.8 + high := ideal[i] * 1.2 + + switch { + case observed[i] < low: + shortage := math.Round((ideal[i] - observed[i]) * 100 / 100) + row.Status = "low" + row.Remarks = fmt.Sprintf("%s is too low. Increase by %.2f. %s", paramNames[i], shortage, fertAdvice[paramNames[i]]) + recommendations = append(recommendations, fmt.Sprintf("%s is too low. Increase by %.2f.", paramNames[i], shortage)) + + case observed[i] > high: + excess := math.Round((observed[i] - ideal[i]) * 100 / 100) + row.Status = "high" + if paramNames[i] == "Humidity" || paramNames[i] == "Rainfall" { + row.Remarks = "Too high." + } else { + row.Remarks = fmt.Sprintf("Too high. Decrement by %.2f. Consider soil amendments.", excess) + recommendations = append(recommendations, fmt.Sprintf("%s is too high. Decrease by %.2f.", paramNames[i], excess)) + } + } + + table = append(table, row) + } + + c.JSON(http.StatusOK, gin.H{ + "crop": req.CropName, + "suitability_score": score, + "table": table, + "recommendations": recommendations, + }) +} + +func (h *PredictHandler) Fertilizer(c *gin.Context) { + var req predictRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()}, + }) + return + } + if !h.resolveFeatures(c, &req) { + return + } + + soilType := req.SoilType + if soilType == "" { + soilType = "Black" + } + cropName := req.CropName + if cropName == "" { + cropName = "Wheat" + } + + features := []float32{ + float32(req.Temp), float32(req.Humidity), float32(req.Moisture), + float32(encodeSoil(soilType)), float32(encodeCrop(cropName)), + float32(req.N), float32(req.K), float32(req.P), + } + + fertilizer, err := h.onnx.PredictFertilizer(features) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{"code": "INTERNAL_ERROR", "message": "Fertilizer model inference failed."}, + }) + return + } + + defN := math.Max(0, 50-req.N) + defP := math.Max(0, 40-req.P) + defK := math.Max(0, 40-req.K) + + resp := gin.H{ + "fertilizer": fertilizer, + "composition": fertilizerComposition(fertilizer), + "deficiencies": gin.H{"N": defN, "P": defP, "K": defK}, + "rationale": "Recommended based on soil and crop requirements.", + "application": applicationAdvice(fertilizer, cropName), + } + if defN > 0 { + resp["nitrogen_advice"] = fmt.Sprintf("Add %.1f kg/ha of nitrogen using Urea or similar.", defN) + } + if defP > 0 { + resp["phosphorus_advice"] = fmt.Sprintf("Add %.1f kg/ha of phosphorus using DAP or similar.", defP) + } + if defK > 0 { + resp["potassium_advice"] = fmt.Sprintf("Add %.1f kg/ha of potassium using Muriate of Potash or similar.", defK) + } + + c.JSON(http.StatusOK, resp) +} + + +var soilEncoder = map[string]float32{ + "Black": 0, "Clayey": 1, "Loamy": 2, "Red": 3, "Sandy": 4, +} + +var cropEncoder = map[string]float32{ + "Barley": 0, "Cotton": 1, "Ground Nuts": 2, "Maize": 3, "Millets": 4, + "Oil seeds": 5, "Paddy": 6, "Pulses": 7, "Sugarcane": 8, "Tobacco": 9, + "Wheat": 10, +} + +var compositionMap = map[string]string{ + "Urea": "46-0-0", "DAP": "18-46-0", "14-35-14": "14-35-14", + "28-28": "28-28-0", "20-20": "20-20-0", "17-17-17": "17-17-17", + "10-26-26": "10-26-26", "Potassium sulfate": "0-0-50", + "Superphosphate": "0-20-0", "Ammonium sulfate": "21-0-0", +} + +func encodeSoil(s string) float32 { + if v, ok := soilEncoder[s]; ok { + return v + } + return 0 +} + +func encodeCrop(s string) float32 { + if v, ok := cropEncoder[s]; ok { + return v + } + return 10 +} + +func fertilizerComposition(name string) string { + if v, ok := compositionMap[name]; ok { + return v + } + return "Varies" +} + +func applicationAdvice(fertilizer, crop string) string { + advice := "" + switch { + case strings.Contains(fertilizer, "Urea"): + advice = "Apply in split doses — half at planting and half during vegetative growth." + case strings.Contains(fertilizer, "DAP"): + advice = "Best applied at planting time, can be mixed with seeds." + case strings.Contains(fertilizer, "Potash"), strings.Contains(fertilizer, "potassium"): + advice = "Apply during early growth stages for best results." + case strings.Contains(fertilizer, "Superphosphate"): + advice = "Apply before planting and incorporate into soil." + case strings.Contains(fertilizer, "Ammonium"): + advice = "Apply in moist soil conditions for best results." + default: + advice = "Apply as basal dose at planting time." + } + lc := strings.ToLower(crop) + switch { + case lc == "rice" || lc == "wheat" || lc == "paddy": + advice += " For cereals, incorporate into soil before planting." + case lc == "cotton" || lc == "sugarcane": + advice += " For commercial crops, apply in bands along the rows." + } + return advice +} diff --git a/backend/handlers/sensor.go b/backend/handlers/sensor.go index 28ae6f5..6d2d79e 100644 --- a/backend/handlers/sensor.go +++ b/backend/handlers/sensor.go @@ -1 +1,208 @@ -package handlers \ No newline at end of file +package handlers + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/gagan-devv/terradetect/backend/db" + "github.com/gagan-devv/terradetect/backend/models" + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type SensorHandler struct { + db *db.Database +} + +func NewSensorHandler(database *db.Database) *SensorHandler { + return &SensorHandler{ + db: database, + } +} + +type esp32Payload struct { + DeviceId string `json:"device_id" binding:"required"` + Temperature float64 `json:"temperature" binding:"required"` + PH float64 `json:"ph" binding:"required"` + Humidity float64 `json:"humidity" binding:"required"` + EC float64 `json:"ec" binding:"required"` + N float64 `json:"N" binding:"required"` + P float64 `json:"P" binding:"required"` + K float64 `json:"K" binding:"required"` + Moisture float64 `json:"moisture" binding:"required"` +} + +func (h *SensorHandler) ReceiveESP32(c *gin.Context) { + apiKey := c.GetHeader("x-api-key") + if apiKey == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "code": "INVALID_API_KEY", + "message": "Missing x-api-key header.", + }, + }) + return + } + + var payload esp32Payload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": gin.H{ + "code": "VALIDATION_ERROR", + "message": err.Error(), + }, + }) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var device models.Device + err := h.db.Devices.FindOne(ctx, bson.M{"device_id": payload.DeviceId}).Decode(&device) + if err != nil || device.APIKey != apiKey { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "code": "INVALID_API_KEY", + "message": "Unauthorized: invalid API key for device.", + }, + }) + return + } + + reading := bson.M{ + "device_id": payload.DeviceId, + "temperature": payload.Temperature, + "ph": payload.PH, + "humidity": payload.Humidity, + "ec": payload.EC, + "n": payload.N, + "p": payload.P, + "k": payload.K, + "moisture": payload.Moisture, + "timestamp": primitive.NewDateTimeFromTime(time.Now()), + } + + _, err = h.db.SensorData.InsertOne(ctx, reading) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "code": "INTERNAL_ERROR", + "message": "Failed to store sensor data.", + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "Sensor data received.", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (h *SensorHandler) Latest(c *gin.Context) { + deviceID, _ := c.Get("device_id") + + ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second) + defer cancel() + + var reading models.SensorReading + err := h.db.SensorData.FindOne(ctx, + bson.M{"device_id": deviceID}, + options.FindOne().SetSort(bson.D{{Key: "timestamp", Value: -1}}), + ).Decode(&reading) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "code": "NOT_FOUND", + "message": "No sensor data available for your device.", + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "temperature": reading.Temperature, + "ph": reading.PH, + "humidity": reading.Humidity, + "ec": reading.EC, + "n": reading.N, + "p": reading.P, + "k": reading.K, + "moisture": reading.Moisture, + }, + "timestamp": reading.Timestamp.Time().UTC().Format(time.RFC3339), + "source": "ESP32", + }) +} + + +func (h *SensorHandler) History(c *gin.Context) { + deviceID, _ := c.Get("device_id") + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "10")) + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 10 + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := bson.M{"device_id": deviceID} + total, err := h.db.SensorData.CountDocuments(ctx, filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to count records."}, + }) + return + } + + skip := int64((page - 1) * perPage) + cursor, err := h.db.SensorData.Find(ctx, filter, + options.Find(). + SetSort(bson.D{{Key: "timestamp", Value: -1}}). + SetSkip(skip). + SetLimit(int64(perPage)), + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to fetch history."}, + }) + return + } + defer cursor.Close(ctx) + + var readings []models.SensorReading + if err := cursor.All(ctx, &readings); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to decode records."}, + }) + return + } + + totalPages := int(total) / perPage + if int(total)%perPage != 0 { + totalPages++ + } + + c.JSON(http.StatusOK, gin.H{ + "history": readings, + "pagination": gin.H{ + "total": total, + "page": page, + "per_page": perPage, + "total_pages": totalPages, + }, + }) +} \ No newline at end of file diff --git a/backend/handlers/weather.go b/backend/handlers/weather.go index 28ae6f5..45e3bed 100644 --- a/backend/handlers/weather.go +++ b/backend/handlers/weather.go @@ -1 +1,77 @@ -package handlers \ No newline at end of file +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/gagan-devv/terradetect/backend/config" +) + +type WeatherHandler struct { + cfg *config.Config +} + +func NewWeatherHandler(cfg *config.Config) *WeatherHandler { + return &WeatherHandler{cfg: cfg} +} + +func (h *WeatherHandler) Get(c *gin.Context) { + lat := c.Query("lat") + lon := c.Query("lon") + if lat == "" || lon == "" { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": gin.H{"code": "VALIDATION_ERROR", "message": "lat and lon query params are required."}, + }) + return + } + + url := fmt.Sprintf( + "https://api.weatherapi.com/v1/current.json?key=%s&q=%s,%s&aqi=no", + h.cfg.WeatherAPIKey, lat, lon, + ) + + resp, err := http.Get(url) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to reach weather API."}, + }) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var raw struct { + Location struct { + Name string `json:"name"` + Region string `json:"region"` + } `json:"location"` + Current struct { + TempC float64 `json:"temp_c"` + Humidity float64 `json:"humidity"` + PrecipMM float64 `json:"precip_mm"` + Condition struct { + Text string `json:"text"` + } `json:"condition"` + } `json:"current"` + } + + if err := json.Unmarshal(body, &raw); err != nil || resp.StatusCode != 200 { + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{"code": "INTERNAL_ERROR", "message": "Invalid response from weather API."}, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "temperature": raw.Current.TempC, + "humidity": raw.Current.Humidity, + "rainfall_mm": raw.Current.PrecipMM, + "condition": raw.Current.Condition.Text, + "location": raw.Location.Name + ", " + raw.Location.Region, + }) +} diff --git a/backend/inference/onnx.go b/backend/inference/onnx.go index 174b43a..9e519e5 100644 --- a/backend/inference/onnx.go +++ b/backend/inference/onnx.go @@ -1 +1,147 @@ -package inference \ No newline at end of file +package inference + +import ( + ort "github.com/yalue/onnxruntime_go" +) + +type Models struct { + cropSession *ort.AdvancedSession + fertilizerSession *ort.AdvancedSession + cropLabels []string + fertilizerLabels []string +} + +var defaultCropLabels = []string{ + "apple", "banana", "blackgram", "chickpea", "coconut", "coffee", + "cotton", "grapes", "jute", "kidneybeans", "lentil", "maize", + "mango", "mothbeans", "mungbean", "muskmelon", "orange", "papaya", + "pigeonpeas", "pomegranate", "rice", "watermelon", "wheat", +} + +var defaultFertilizerLabels = []string{ + "10-26-26", "14-35-14", "17-17-17", "20-20", "28-28", + "DAP", "Potassium sulfate", "Superphosphate", "Urea", "Ammonium sulfate", +} + +func LoadModels(cropPath, fertilizerPath string) (*Models, error) { + ort.SetSharedLibraryPath(getOrtLibPath()) + + if err := ort.InitializeEnvironment(); err != nil { + return nil, err + } + + cropSession, err := createSession(cropPath, 7) + if err != nil { + return nil, err + } + + fertSession, err := createSession(fertilizerPath, 8) + if err != nil { + return nil, err + } + + return &Models{ + cropSession: cropSession, + fertilizerSession: fertSession, + cropLabels: defaultCropLabels, + fertilizerLabels: defaultFertilizerLabels, + }, nil +} + +func (m *Models) PredictCrop(features []float32) (string, float64, error) { + input, err := ort.NewTensor(ort.NewShape(1, int64(len(features))), features) + if err != nil { + return "", 0, err + } + + defer input.Destroy() + + outputLabel, err := ort.NewEmptyTensor[int64](ort.NewShape(1)) + if err != nil { + return "", 0, err + } + defer outputLabel.Destroy() + + outputProba, err := ort.NewEmptyTensor[float32](ort.NewShape(1, int64(len(m.cropLabels)))) + if err != nil { + return "", 0, err + } + defer outputProba.Destroy() + + err = m.cropSession.Run() + if err != nil { + return "", 0, err + } + + idx := int(outputLabel.GetData()[0]) + probas := outputProba.GetData() + maxProba := float64(probas[idx]) + + label := "unknown" + if idx >= 0 && idx < len(m.cropLabels) { + label = m.cropLabels[idx] + } + + return label, maxProba * 100, nil +} + +func (m *Models) PredictFertilizer(features []float32) (string, error) { + input, err := ort.NewTensor(ort.NewShape(1, int64(len(features))), features) + if err != nil { + return "", err + } + defer input.Destroy() + + outputLabel, err := ort.NewEmptyTensor[int64](ort.NewShape(1)) + if err != nil { + return "", err + } + defer outputLabel.Destroy() + + err = m.fertilizerSession.Run() + if err != nil { + return "", err + } + + idx := int(outputLabel.GetData()[0]) + label := "unknown" + if idx >= 0 && idx < len(m.fertilizerLabels) { + label = m.fertilizerLabels[idx] + } + + return label, nil +} + +func createSession( + modelPath string, inputSize int) (*ort.AdvancedSession, error) { + inputShape := ort.NewShape(1, int64(inputSize)) + inputTensor, err := ort.NewEmptyTensor[float32](inputShape) + if err != nil { + return nil, err + } + + outputTensor, err := ort.NewEmptyTensor[float32](inputShape) + if err != nil { + return nil, err + } + + options, err := ort.NewSessionOptions() + if err != nil { + return nil, err + } + + session, err := ort.NewAdvancedSession( + modelPath, + []string{"float_input"}, + []string{"label"}, + []ort.ArbitraryTensor{inputTensor}, + []ort.ArbitraryTensor{outputTensor}, + options, + ) + + return session, err +} + +func getOrtLibPath() string { + return "/usr/local/lib/libonnxruntime.so" +} diff --git a/backend/main.go b/backend/main.go index 5f80139..86e2271 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,7 +1,78 @@ package main -import "fmt" +import ( + "log" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + "github.com/gagan-devv/terradetect/backend/config" + "github.com/gagan-devv/terradetect/backend/db" + "github.com/gagan-devv/terradetect/backend/handlers" + "github.com/gagan-devv/terradetect/backend/inference" + "github.com/gagan-devv/terradetect/backend/middleware" +) func main() { - fmt.Println("All the routes will be implemented here.") + cfg := config.Load() + + database, err := db.Connect(cfg.MongoURI, cfg.DBName) + if err != nil { + log.Fatalf("MongoDB connection failed: %v", err) + } + log.Println("Connected to MongoDB") + + onnx, err := inference.LoadModels(cfg.CropModelPath, cfg.FertilizerModelPath) + if err != nil { + log.Fatalf("ONNX model load failed: %v", err) + } + log.Println("ONNX models loaded") + + r := gin.Default() + + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "OPTIONS"}, + AllowHeaders: []string{"Authorization", "Content-Type", "x-api-key"}, + AllowCredentials: false, + })) + + // ── Handlers ───────────────────────────────────────────────────────────── + authH := handlers.NewAuthHandler(database, cfg) + sensorH := handlers.NewSensorHandler(database) + deviceH := handlers.NewDeviceHandler(database) + predictH := handlers.NewPredictHandler(database, onnx) + weatherH := handlers.NewWeatherHandler(cfg) + + // ── Legacy ESP32 endpoint (no /api/v1 prefix — avoid reflashing devices) ─ + r.POST("/api/esp32", + middleware.NewLimiter("2-S"), + sensorH.ReceiveESP32, + ) + + // ── Public routes ───────────────────────────────────────────────────────── + r.POST("/api/v1/auth/register", middleware.NewLimiter("3-M"), authH.Register) + r.POST("/api/v1/auth/login", middleware.NewLimiter("5-M"), authH.Login) + r.POST("/api/v1/auth/refresh", authH.Refresh) + r.POST("/api/v1/device/check", deviceH.Check) + + // ── Protected routes ────────────────────────────────────────────────────── + protected := r.Group("/api/v1") + protected.Use(middleware.Auth(cfg.SecretKey)) + { + protected.POST("/auth/logout", authH.Logout) + + protected.GET("/sensor/latest", sensorH.Latest) + protected.GET("/sensor/history", sensorH.History) + + protected.POST("/predict/crop", predictH.Crop) + protected.POST("/predict/suitability", predictH.Suitability) + protected.POST("/predict/fertilizer", predictH.Fertilizer) + + protected.GET("/weather", weatherH.Get) + } + + log.Printf("TerraDetect backend listening on :%s", cfg.Port) + if err := r.Run(":" + cfg.Port); err != nil { + log.Fatalf("Server failed: %v", err) + } } \ No newline at end of file diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index e0b7641..f68424f 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -9,29 +9,52 @@ import ( ) func Auth(secretKey string) gin.HandlerFunc { - return func (c *gin.Context) { + return func(c *gin.Context) { header := c.GetHeader("Authorization") - if !strings.HasPrefix(header, "Bearer ") { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": gin.H{"code": "UNAUTHORIZED", "message": "Missing or invalid token."}, + "error": gin.H{ + "code": "UNAUTHORIZED", + "message": "Missing or invalid Authorization header.", + }, }) - return + return } tokenStr := strings.TrimPrefix(header, "Bearer ") - token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) { - return []byte(secretKey), nil - }, jwt.WithValidMethods([]string{"HS256"})) + token, err := jwt.Parse(tokenStr, + func(t *jwt.Token) (any, error) { + return []byte(secretKey), nil + }, + jwt.WithValidMethods([]string{"HS256"}), + ) if err != nil || !token.Valid { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": gin.H{"code": "UNAUTHORIZED", "message": "Invalid or expired token."}, + "error": gin.H{ + "code": "UNAUTHORIZED", + "message": "Invalid or expired token.", + }, + }) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{"code": "UNAUTHORIZED", "message": "Invalid token claims."}, }) - return + return } - - claims := token.Claims.(jwt.MapClaims) + + // Reject refresh tokens used as access tokens + if claims["type"] == "refresh" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{"code": "UNAUTHORIZED", "message": "Use access token, not refresh token."}, + }) + return + } + c.Set("username", claims["username"]) c.Set("device_id", claims["device_id"]) c.Next() diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go index 3d780d0..9d4c426 100644 --- a/backend/middleware/ratelimit.go +++ b/backend/middleware/ratelimit.go @@ -8,7 +8,10 @@ import ( ) func NewLimiter (rateStr string) gin.HandlerFunc { - rate, _ := limiter.NewRateFromFormatted(rateStr) + rate, err := limiter.NewRateFromFormatted(rateStr) + if err != nil { + panic("invalid rate format: " + rateStr) + } store := memory.NewStore() instance := limiter.New(store, rate) return ginlimiter.NewMiddleware(instance) diff --git a/backend/models/device.go b/backend/models/device.go index 8315cab..b4299dd 100644 --- a/backend/models/device.go +++ b/backend/models/device.go @@ -3,9 +3,9 @@ package models import "go.mongodb.org/mongo-driver/bson/primitive" type Device struct { - ID primitive.ObjectID `bson:"_id,omitempty"` - DeviceID string `bson:"device_id"` - APIKey string `bson:"api_key"` - Registered bool `bson:"registered"` - CreatedAt primitive.DateTime `bson:"created_at"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"_id"` + DeviceID string `bson:"device_id" json:"device_id"` + APIKey string `bson:"api_key" json:"api_key"` + Registered bool `bson:"registered" json:"registered"` + CreatedAt primitive.DateTime `bson:"created_at" json:"created_at"` } \ No newline at end of file diff --git a/backend/models/sensor.go b/backend/models/sensor.go index 6d7c716..88ce9d1 100644 --- a/backend/models/sensor.go +++ b/backend/models/sensor.go @@ -3,15 +3,15 @@ package models import "go.mongodb.org/mongo-driver/bson/primitive" type SensorReading struct { - ID primitive.ObjectID `bson:"_id,omitempty"` - DeviceID string `bson:"device_id"` - Temperature float64 `bson:"temperature"` - PH float64 `bson:"ph"` - Humidity float64 `bson:"humidity"` - EC float64 `bson:"ec"` - N float64 `bson:"N"` - P float64 `bson:"P"` - K float64 `bson:"K"` - Moisture float64 `bson:"moisture"` - Timestamp primitive.DateTime `bson:"timestamp"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"_id"` + DeviceID string `bson:"device_id" json:"device_id"` + Temperature float64 `bson:"temperature" json:"temperature"` + PH float64 `bson:"ph" json:"ph"` + Humidity float64 `bson:"humidity" json:"humidity"` + EC float64 `bson:"ec" json:"ec"` + N float64 `bson:"N" json:"N"` + P float64 `bson:"P" json:"P"` + K float64 `bson:"K" json:"K"` + Moisture float64 `bson:"moisture" json:"moisture"` + Timestamp primitive.DateTime `bson:"timestamp" json:"timestamp"` } diff --git a/backend/models/user.go b/backend/models/user.go index efb768b..ed9c4f4 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -3,9 +3,9 @@ package models import "go.mongodb.org/mongo-driver/bson/primitive" type User struct { - ID primitive.ObjectID `bson:"_id,omitempty"` - Username string `bson:"username"` - PasswordHash string `bson:"password_hash"` - DeviceID string `bson:"device_id"` - CreatedAt primitive.DateTime `bson:"created_at"` -} + ID primitive.ObjectID `bson:"_id,omitempty" json:"_id"` + Username string `bson:"username" json:"username"` + PasswordHash string `bson:"password_hash" json:"-"` + DeviceID string `bson:"device_id" json:"device_id"` + CreatedAt primitive.DateTime `bson:"created_at" json:"created_at"` +} \ No newline at end of file