From a3eab3863945edf0db8cb393da1b6201de1ccf54 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sat, 23 Feb 2019 15:17:13 +0100 Subject: [PATCH 1/6] lucifer-server, sqlite, controllers: Added /api/light/ --- cmd/lucifer-server/main.go | 2 + controllers/group-controller.go | 26 +------ controllers/light-controller.go | 106 ++++++++++++++++++++++++++++ database/sqlite/group-repository.go | 18 +++-- database/sqlite/light-repository.go | 5 ++ 5 files changed, 127 insertions(+), 30 deletions(-) create mode 100644 controllers/light-controller.go diff --git a/cmd/lucifer-server/main.go b/cmd/lucifer-server/main.go index 39c771f..005ca1b 100644 --- a/cmd/lucifer-server/main.go +++ b/cmd/lucifer-server/main.go @@ -44,12 +44,14 @@ func main() { // Controllers userController := controllers.NewUserController(sqlite.UserRepository, sqlite.SessionRepository) groupController := controllers.NewGroupController(sqlite.GroupRepository, sqlite.UserRepository, sqlite.LightRepository) + lightController := controllers.NewLightController(sqlite.GroupRepository, sqlite.UserRepository, sqlite.LightRepository) // Router router := mux.NewRouter() router.Use(middlewares.Session(sqlite.SessionRepository, sqlite.UserRepository)) groupController.Mount(router, "/api/group/") userController.Mount(router, "/api/user/") + lightController.Mount(router, "/api/light/") // Background Tasks go lightService.SyncLoop(context.TODO()) diff --git a/controllers/group-controller.go b/controllers/group-controller.go index 2759cde..97ca3e6 100644 --- a/controllers/group-controller.go +++ b/controllers/group-controller.go @@ -21,20 +21,10 @@ type GroupController struct { // getGroups (`GET /:id`): Get user by id func (c *GroupController) getGroups(w http.ResponseWriter, r *http.Request) { - session := models.SessionFromContext(r.Context()) - if session == nil { - respond.Error(w, http.StatusForbidden, "permission_denied", "You must log in") - return - } + user := models.UserFromContext(r.Context()) - user, err := c.users.FindByID(r.Context(), session.UserID) + groups, err := c.groups.ListByUser(r.Context(), *user) if err != nil { - respond.Error(w, http.StatusForbidden, "permission_denied", "You must log in") - return - } - - groups, err := c.groups.ListByUser(r.Context(), user) - if err != nil && err != sql.ErrNoRows { log.Printf("Getting groups for user %s (%d) failed: %s", user.Name, user.ID, err) respond.Error(w, http.StatusInternalServerError, "internal_error", "Failed to get groups.") return @@ -46,10 +36,6 @@ func (c *GroupController) getGroups(w http.ResponseWriter, r *http.Request) { // getGroup (`GET /:id`): Get user by id func (c *GroupController) getGroup(w http.ResponseWriter, r *http.Request) { session := models.SessionFromContext(r.Context()) - if session == nil { - respond.Error(w, http.StatusForbidden, "permission_denied", "You must log in") - return - } idStr := mux.Vars(r)["id"] id, err := strconv.ParseInt(idStr, 10, 32) @@ -70,10 +56,6 @@ func (c *GroupController) getGroup(w http.ResponseWriter, r *http.Request) { // getGroupLights (`GET /:id/light/`): Get user by id func (c *GroupController) getGroupLights(w http.ResponseWriter, r *http.Request) { session := models.SessionFromContext(r.Context()) - if session == nil { - respond.Error(w, http.StatusForbidden, "permission_denied", "You must log in") - return - } idStr := mux.Vars(r)["id"] id, err := strconv.ParseInt(idStr, 10, 32) @@ -101,10 +83,6 @@ func (c *GroupController) getGroupLights(w http.ResponseWriter, r *http.Request) // getGroupLight (`GET /:group_id/light/:id`): Get user by id func (c *GroupController) getGroupLight(w http.ResponseWriter, r *http.Request) { session := models.SessionFromContext(r.Context()) - if session == nil { - respond.Error(w, http.StatusForbidden, "permission_denied", "You must log in") - return - } groupIDStr := mux.Vars(r)["group_id"] groupID, err := strconv.ParseInt(groupIDStr, 10, 32) diff --git a/controllers/light-controller.go b/controllers/light-controller.go new file mode 100644 index 0000000..1012f3c --- /dev/null +++ b/controllers/light-controller.go @@ -0,0 +1,106 @@ +package controllers + +import ( + "database/sql" + "net/http" + "strconv" + + "git.aiterp.net/lucifer/lucifer/internal/httperr" + + "git.aiterp.net/lucifer/lucifer/internal/respond" + "git.aiterp.net/lucifer/lucifer/models" + "github.com/gorilla/mux" + "golang.org/x/sync/errgroup" +) + +// The LightController is a controller for /api/light/. +type LightController struct { + groups models.GroupRepository + users models.UserRepository + lights models.LightRepository +} + +// getLights (`GET /:id`): Get user by id +func (c *LightController) getLights(w http.ResponseWriter, r *http.Request) { + user := models.UserFromContext(r.Context()) + + groups, err := c.groups.ListByUser(r.Context(), *user) + if err != nil { + httperr.Respond(w, err) + return + } + + allLights := make([]models.Light, 0, len(groups)*8) + + eg, egCtx := errgroup.WithContext(r.Context()) + for i := range groups { + group := groups[i] + + eg.Go(func() error { + lights, err := c.lights.ListByGroup(egCtx, group) + if err != nil { + return err + } + + allLights = append(allLights, lights...) + + return nil + }) + } + + if err := eg.Wait(); err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, allLights) +} + +func (c *LightController) getLight(w http.ResponseWriter, r *http.Request) { + user := models.UserFromContext(r.Context()) + + idStr := mux.Vars(r)["id"] + id, err := strconv.ParseInt(idStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The light id "+idStr+" is not valid.") + return + } + + light, err := c.lights.FindByID(r.Context(), int(id)) + if err == sql.ErrNoRows { + httperr.Respond(w, httperr.NotFound("Light")) + return + } else if err != nil { + httperr.Respond(w, err) + return + } + + group, err := c.groups.FindByID(r.Context(), light.GroupID) + if err == sql.ErrNoRows { + httperr.Respond(w, httperr.NotFound("Group")) + return + } else if err != nil { + httperr.Respond(w, err) + return + } + + if !group.Permission(user.ID).Read { + respond.Error(w, http.StatusForbidden, "permission_denied", "You do not have permission to see this light.") + return + } + + respond.Data(w, light) +} + +// Mount mounts the controller +func (c *LightController) Mount(router *mux.Router, prefix string) { + sub := router.PathPrefix(prefix).Subrouter() + + sub.HandleFunc("/", c.getLights).Methods("GET") + sub.HandleFunc("/{id}", c.getLight).Methods("GET") +} + +// NewLightController creates a new LightController. +func NewLightController(groups models.GroupRepository, users models.UserRepository, lights models.LightRepository) *LightController { + return &LightController{groups: groups, users: users, lights: lights} +} diff --git a/database/sqlite/group-repository.go b/database/sqlite/group-repository.go index f60fa67..0028217 100644 --- a/database/sqlite/group-repository.go +++ b/database/sqlite/group-repository.go @@ -2,6 +2,7 @@ package sqlite import ( "context" + "database/sql" "golang.org/x/sync/errgroup" @@ -37,16 +38,16 @@ func (r *groupRepository) FindByLight(ctx context.Context, light models.Light) ( group := models.Group{} err := db.GetContext(ctx, &group, query, light.ID) - if err != nil { + if err != nil && err != sql.ErrNoRows { return models.Group{}, err } err = db.SelectContext(ctx, &group.Permissions, "SELECT * FROM group_permission WHERE group_id=?", group.ID) - if err != nil { + if err != nil && err != sql.ErrNoRows { return models.Group{}, err } - return group, err + return group, nil } func (r *groupRepository) List(ctx context.Context) ([]models.Group, error) { @@ -66,7 +67,7 @@ func (r *groupRepository) List(ctx context.Context) ([]models.Group, error) { } err = eg.Wait() - if err != nil { + if err != nil && err != sql.ErrNoRows { return nil, err } @@ -82,7 +83,7 @@ func (r *groupRepository) ListByUser(ctx context.Context, user models.User) ([]m groups := make([]models.Group, 0, 16) err := db.SelectContext(ctx, &groups, query, user.ID) - if err != nil { + if err != nil && err != sql.ErrNoRows { return nil, err } @@ -91,7 +92,12 @@ func (r *groupRepository) ListByUser(ctx context.Context, user models.User) ([]m group := &groups[i] eg.Go(func() error { - return db.SelectContext(egCtx, &group.Permissions, "SELECT * FROM group_permission WHERE group_id=?", group.ID) + err := db.SelectContext(egCtx, &group.Permissions, "SELECT * FROM group_permission WHERE group_id=?", group.ID) + if err != nil && err != sql.ErrNoRows { + return err + } + + return nil }) } diff --git a/database/sqlite/light-repository.go b/database/sqlite/light-repository.go index e84a5e0..5a44bb8 100644 --- a/database/sqlite/light-repository.go +++ b/database/sqlite/light-repository.go @@ -2,6 +2,7 @@ package sqlite import ( "context" + "database/sql" "git.aiterp.net/lucifer/lucifer/models" ) @@ -43,6 +44,10 @@ func (r *lightRepository) ListByGroup(ctx context.Context, group models.Group) ( lights := make([]models.Light, 0, 64) err := db.SelectContext(ctx, &lights, "SELECT * FROM light WHERE group_id=?", group.ID) + if err == sql.ErrNoRows { + err = nil + } + return lights, err } From 3e016efcac936f6ebb0f5fc9ea07bcf6c6068cd3 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sun, 24 Feb 2019 11:07:12 +0100 Subject: [PATCH 2/6] Added PATCH/PUT /api/light/{id} --- cmd/lucifer-server/main.go | 2 +- controllers/light-controller.go | 135 ++++++++++++++++++++++++++------ light/service.go | 34 +++++++- models/light.go | 22 ++++++ 4 files changed, 167 insertions(+), 26 deletions(-) diff --git a/cmd/lucifer-server/main.go b/cmd/lucifer-server/main.go index 005ca1b..142ddf2 100644 --- a/cmd/lucifer-server/main.go +++ b/cmd/lucifer-server/main.go @@ -44,7 +44,7 @@ func main() { // Controllers userController := controllers.NewUserController(sqlite.UserRepository, sqlite.SessionRepository) groupController := controllers.NewGroupController(sqlite.GroupRepository, sqlite.UserRepository, sqlite.LightRepository) - lightController := controllers.NewLightController(sqlite.GroupRepository, sqlite.UserRepository, sqlite.LightRepository) + lightController := controllers.NewLightController(lightService, sqlite.GroupRepository, sqlite.UserRepository, sqlite.LightRepository) // Router router := mux.NewRouter() diff --git a/controllers/light-controller.go b/controllers/light-controller.go index 1012f3c..a2cb5ae 100644 --- a/controllers/light-controller.go +++ b/controllers/light-controller.go @@ -2,9 +2,12 @@ package controllers import ( "database/sql" + "encoding/json" "net/http" "strconv" + "git.aiterp.net/lucifer/lucifer/light" + "git.aiterp.net/lucifer/lucifer/internal/httperr" "git.aiterp.net/lucifer/lucifer/internal/respond" @@ -15,9 +18,10 @@ import ( // The LightController is a controller for /api/light/. type LightController struct { - groups models.GroupRepository - users models.UserRepository - lights models.LightRepository + service *light.Service + groups models.GroupRepository + users models.UserRepository + lights models.LightRepository } // getLights (`GET /:id`): Get user by id @@ -57,35 +61,89 @@ func (c *LightController) getLights(w http.ResponseWriter, r *http.Request) { } func (c *LightController) getLight(w http.ResponseWriter, r *http.Request) { - user := models.UserFromContext(r.Context()) - - idStr := mux.Vars(r)["id"] - id, err := strconv.ParseInt(idStr, 10, 32) + _, light, err := c.findLight(r) if err != nil { - respond.Error(w, http.StatusBadRequest, "invalid_id", "The light id "+idStr+" is not valid.") + httperr.Respond(w, err) return } - light, err := c.lights.FindByID(r.Context(), int(id)) - if err == sql.ErrNoRows { - httperr.Respond(w, httperr.NotFound("Light")) - return - } else if err != nil { - httperr.Respond(w, err) + respond.Data(w, light) +} + +func (c *LightController) updateLight(w http.ResponseWriter, r *http.Request) { + patch := struct { + Color *string `json:"color,omitempty"` + Brightness *int `json:"brightness,omitempty"` + On *bool `json:"on,omitempty"` + Name *string `json:"name,omitempty"` + GroupID *int `json:"groupId,omitempty"` + }{} + + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_json", "Input is not valid JSON.") return } - group, err := c.groups.FindByID(r.Context(), light.GroupID) - if err == sql.ErrNoRows { - httperr.Respond(w, httperr.NotFound("Group")) - return - } else if err != nil { + group, light, err := c.findLight(r) + if err != nil { httperr.Respond(w, err) return } - if !group.Permission(user.ID).Read { - respond.Error(w, http.StatusForbidden, "permission_denied", "You do not have permission to see this light.") + if patch.Color != nil { + err := light.SetColor(*patch.Color) + if err != nil { + httperr.Respond(w, err) + return + } + } + if patch.Name != nil { + if len(*patch.Name) == 0 { + respond.Error(w, 400, "invalid_name", "The name is empty.") + return + } + + light.Name = *patch.Name + } + if patch.Brightness != nil { + if *patch.Brightness < 0 || *patch.Brightness > 255 { + respond.Error(w, 400, "invalid_brightness", "The brightness must be a value between 0-255 inclusive.") + return + } + + light.Brightness = uint8(*patch.Brightness) + } + if patch.On != nil { + light.On = *patch.On + } + if patch.GroupID != nil && *patch.GroupID != light.GroupID { + user := models.UserFromContext(r.Context()) + + if !group.Permission(user.ID).Delete { + respond.Error(w, 403, "cannot_move_out", "You are not permitted to delete lights from group.") + return + } + + // Anyone is allowed to move lights TO group 0 (Lonely Lights) as it's the closest thing there is + // to deleting lights. + if *patch.GroupID != 0 { + targetGroup, err := c.groups.FindByID(r.Context(), *patch.GroupID) + if err != nil { + respond.Error(w, 404, "group_not_found", "The group could not be found.") + return + } + if !targetGroup.Permission(user.ID).Create { + respond.Error(w, 403, "cannot_move_in", "You are not permitted to create lights in target group.") + return + } + } + + light.GroupID = *patch.GroupID + } + + err = c.service.UpdateLight(r.Context(), light) + if err != nil { + httperr.Respond(w, err) return } @@ -98,9 +156,40 @@ func (c *LightController) Mount(router *mux.Router, prefix string) { sub.HandleFunc("/", c.getLights).Methods("GET") sub.HandleFunc("/{id}", c.getLight).Methods("GET") + sub.HandleFunc("/{id}", c.updateLight).Methods("PATCH", "PUT") +} + +func (c *LightController) findLight(r *http.Request) (models.Group, models.Light, error) { + user := models.UserFromContext(r.Context()) + + idStr := mux.Vars(r)["id"] + id, err := strconv.ParseInt(idStr, 10, 32) + if err != nil { + return models.Group{}, models.Light{}, &httperr.Error{Status: http.StatusForbidden, Kind: "invalid_id", Message: "The light id " + idStr + " is not valid."} + } + + light, err := c.lights.FindByID(r.Context(), int(id)) + if err == sql.ErrNoRows { + return models.Group{}, models.Light{}, httperr.NotFound("Light") + } else if err != nil { + return models.Group{}, models.Light{}, err + } + + group, err := c.groups.FindByID(r.Context(), light.GroupID) + if err == sql.ErrNoRows { + return models.Group{}, models.Light{}, httperr.NotFound("Group") + } else if err != nil { + return models.Group{}, models.Light{}, err + } + + if !group.Permission(user.ID).Read { + return models.Group{}, models.Light{}, &httperr.Error{Status: http.StatusForbidden, Kind: "permission_denied", Message: "Thou canst not see the light."} + } + + return group, light, nil } // NewLightController creates a new LightController. -func NewLightController(groups models.GroupRepository, users models.UserRepository, lights models.LightRepository) *LightController { - return &LightController{groups: groups, users: users, lights: lights} +func NewLightController(service *light.Service, groups models.GroupRepository, users models.UserRepository, lights models.LightRepository) *LightController { + return &LightController{service: service, groups: groups, users: users, lights: lights} } diff --git a/light/service.go b/light/service.go index 664757b..44ef5b6 100644 --- a/light/service.go +++ b/light/service.go @@ -2,18 +2,23 @@ package light import ( "context" - "errors" "log" + "net/http" + "sync" "time" + "git.aiterp.net/lucifer/lucifer/internal/httperr" + "git.aiterp.net/lucifer/lucifer/models" ) // ErrUnknownDriver is returned by any function asking for a driver name if the driver specified doesn't exist. -var ErrUnknownDriver = errors.New("Unknown driver specified") +var ErrUnknownDriver = &httperr.Error{Status: http.StatusNotImplemented, Kind: "unknown_driver", Message: "Unknown light driver"} // A Service wraps the repos for lights and bridges and takes care of the business logic. type Service struct { + mutex sync.Mutex + bridges models.BridgeRepository lights models.LightRepository } @@ -47,6 +52,9 @@ func (s *Service) DirectConnect(ctx context.Context, driver string, addr string, // SyncLights syncs all lights in a bridge with the state in the database. func (s *Service) SyncLights(ctx context.Context, bridge models.Bridge) error { + s.mutex.Lock() + defer s.mutex.Unlock() + d, ok := drivers[bridge.Driver] if !ok { return ErrUnknownDriver @@ -97,6 +105,28 @@ func (s *Service) SyncLights(ctx context.Context, bridge models.Bridge) error { return nil } +// UpdateLight updates the light immediately. +func (s *Service) UpdateLight(ctx context.Context, light models.Light) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + err := s.lights.Update(ctx, light) + if err != nil { + return err + } + + bridge, err := s.bridges.FindByID(ctx, light.BridgeID) + if err != nil { + return httperr.NotFound("Bridge") + } + d, ok := drivers[bridge.Driver] + if !ok { + return ErrUnknownDriver + } + + return d.Apply(ctx, bridge, light) +} + // SyncLoop runs synclight on all bridges twice every second until the context is // done. func (s *Service) SyncLoop(ctx context.Context) { diff --git a/models/light.go b/models/light.go index fa211ca..820962a 100644 --- a/models/light.go +++ b/models/light.go @@ -4,6 +4,9 @@ import ( "context" "encoding/hex" "errors" + "strings" + + "git.aiterp.net/lucifer/lucifer/internal/httperr" ) // ErrMalformedColor is returned by light.ColorRGBf when the color value is invalid. @@ -21,6 +24,25 @@ type Light struct { Brightness uint8 `json:"brightness" db:"brightness"` } +// SetColor sets the color to a hex string, or returns an error if it's not valid. +func (light *Light) SetColor(hexStr string) error { + if len(hexStr) == 7 && hexStr[0] == '#' { + hexStr = hexStr[1:] + } + if len(hexStr) != 6 { + return httperr.Error{Status: 400, Kind: "invalid_color", Message: hexStr + " is not a valid color."} + } + + _, err := hex.DecodeString(hexStr) + if err != nil { + return httperr.Error{Status: 400, Kind: "invalid_color", Message: hexStr + " is not a valid color."} + } + + light.Color = strings.ToUpper(hexStr) + + return nil +} + // SetColorRGB sets the color with an RGB value. func (light *Light) SetColorRGB(r, g, b uint8) { light.Color = hex.EncodeToString([]byte{r, g, b}) From d97ad960b47c3d127989934fe3181ebd5bcba003 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Thu, 28 Feb 2019 21:04:16 +0100 Subject: [PATCH 3/6] GroupController: Added POST /api/group/ and PUT/PATCH /api/group/:group_id --- controllers/group-controller.go | 107 +++++++++++++++++++++++++--- database/sqlite/group-repository.go | 17 ++++- 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/controllers/group-controller.go b/controllers/group-controller.go index 97ca3e6..45aa908 100644 --- a/controllers/group-controller.go +++ b/controllers/group-controller.go @@ -2,11 +2,14 @@ package controllers import ( "database/sql" + "encoding/json" "fmt" "log" "net/http" "strconv" + "git.aiterp.net/lucifer/lucifer/internal/httperr" + "git.aiterp.net/lucifer/lucifer/internal/respond" "git.aiterp.net/lucifer/lucifer/models" "github.com/gorilla/mux" @@ -33,11 +36,11 @@ func (c *GroupController) getGroups(w http.ResponseWriter, r *http.Request) { respond.Data(w, groups) } -// getGroup (`GET /:id`): Get user by id +// getGroup (`GET /:group_id`): Get user by id func (c *GroupController) getGroup(w http.ResponseWriter, r *http.Request) { session := models.SessionFromContext(r.Context()) - idStr := mux.Vars(r)["id"] + idStr := mux.Vars(r)["group_id"] id, err := strconv.ParseInt(idStr, 10, 32) if err != nil { respond.Error(w, http.StatusBadRequest, "invalid_id", "The id"+idStr+"is not valid.") @@ -53,11 +56,11 @@ func (c *GroupController) getGroup(w http.ResponseWriter, r *http.Request) { respond.Data(w, group) } -// getGroupLights (`GET /:id/light/`): Get user by id +// getGroupLights (`GET /:group_id/light/`): Get user by id func (c *GroupController) getGroupLights(w http.ResponseWriter, r *http.Request) { session := models.SessionFromContext(r.Context()) - idStr := mux.Vars(r)["id"] + idStr := mux.Vars(r)["group_id"] id, err := strconv.ParseInt(idStr, 10, 32) if err != nil { respond.Error(w, http.StatusBadRequest, "invalid_id", "The id "+idStr+" is not valid.") @@ -80,7 +83,7 @@ func (c *GroupController) getGroupLights(w http.ResponseWriter, r *http.Request) respond.Data(w, lights) } -// getGroupLight (`GET /:group_id/light/:id`): Get user by id +// getGroupLight (`GET /:group_id/light/:light_id`): Get user by id func (c *GroupController) getGroupLight(w http.ResponseWriter, r *http.Request) { session := models.SessionFromContext(r.Context()) @@ -91,7 +94,7 @@ func (c *GroupController) getGroupLight(w http.ResponseWriter, r *http.Request) return } - idStr := mux.Vars(r)["id"] + idStr := mux.Vars(r)["light_id"] id, err := strconv.ParseInt(idStr, 10, 32) if err != nil { respond.Error(w, http.StatusBadRequest, "invalid_id", "The light id "+idStr+" is not valid.") @@ -114,14 +117,100 @@ func (c *GroupController) getGroupLight(w http.ResponseWriter, r *http.Request) respond.Data(w, light) } +// postGroup (`POST /`): Create a group. +func (c *GroupController) postGroup(w http.ResponseWriter, r *http.Request) { + user := models.UserFromContext(r.Context()) + + postData := struct { + Name string + }{} + if err := json.NewDecoder(r.Body).Decode(&postData); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_json", "Input is not valid JSON.") + return + } + if postData.Name == "" { + respond.Error(w, http.StatusBadRequest, "invalid_name", "The name cannot be blank.") + return + } + + group, err := c.groups.Insert(r.Context(), models.Group{Name: postData.Name}) + if err != nil { + httperr.Respond(w, err) + return + } + + permission := models.GroupPermission{ + GroupID: group.ID, + UserID: user.ID, + Read: true, + Write: true, + Delete: true, + Create: true, + Manage: true, + } + + err = c.groups.UpdatePermissions(r.Context(), permission) + if err != nil { + httperr.Respond(w, err) + return + } + + group.Permissions = []models.GroupPermission{permission} + respond.Data(w, group) +} + +// updateGroup (`PUT/PATCH /:group_id`): Create a group. +func (c *GroupController) updateGroup(w http.ResponseWriter, r *http.Request) { + user := models.UserFromContext(r.Context()) + + groupIDStr := mux.Vars(r)["group_id"] + groupID, err := strconv.ParseInt(groupIDStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The group id "+groupIDStr+" is not valid.") + return + } + + postData := struct { + Name string + }{} + if err := json.NewDecoder(r.Body).Decode(&postData); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_json", "Input is not valid JSON.") + return + } + if postData.Name == "" { + respond.Error(w, http.StatusBadRequest, "invalid_name", "The name cannot be blank.") + return + } + + group, err := c.groups.FindByID(r.Context(), int(groupID)) + if err != nil { + respond.Error(w, http.StatusNotFound, "group_not_found", "The group cannot be found or you are not authorized to view it.") + return + } else if !group.Permission(user.ID).Manage { + respond.Error(w, http.StatusNotFound, "permission_denied", "Your transgression will be remembered.") + return + } + + group.Name = postData.Name + err = c.groups.Update(r.Context(), group) + if err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, group) +} + // Mount mounts the controller func (c *GroupController) Mount(router *mux.Router, prefix string) { sub := router.PathPrefix(prefix).Subrouter() sub.HandleFunc("/", c.getGroups).Methods("GET") - sub.HandleFunc("/{id}", c.getGroup).Methods("GET") - sub.HandleFunc("/{id}/light/", c.getGroupLights).Methods("GET") - sub.HandleFunc("/{group_id}/light/{id}", c.getGroupLight).Methods("GET") + sub.HandleFunc("/", c.postGroup).Methods("POST") + sub.HandleFunc("/{group_id}", c.getGroup).Methods("GET") + sub.HandleFunc("/{group_id}", c.updateGroup).Methods("PUT", "PATCH") + sub.HandleFunc("/{group_id}/light/", c.getGroupLights).Methods("GET") + sub.HandleFunc("/{group_id}/light/{light_id}", c.getGroupLight).Methods("GET") } // NewGroupController creates a new GroupController. diff --git a/database/sqlite/group-repository.go b/database/sqlite/group-repository.go index 0028217..ee75f80 100644 --- a/database/sqlite/group-repository.go +++ b/database/sqlite/group-repository.go @@ -110,11 +110,22 @@ func (r *groupRepository) ListByUser(ctx context.Context, user models.User) ([]m } func (r *groupRepository) Insert(ctx context.Context, group models.Group) (models.Group, error) { - panic("not implemented") + res, err := db.NamedExecContext(ctx, "INSERT INTO `group` (name) VALUES(:name)", group) + if err != nil { + return models.Group{}, err + } + + id, err := res.LastInsertId() + if err != nil { + return models.Group{}, err + } + + group.ID = int(id) + return group, nil } func (r *groupRepository) Update(ctx context.Context, group models.Group) error { - _, err := db.NamedExecContext(ctx, "UPDATE group SET name=:name WHERE id=:id", group) + _, err := db.NamedExecContext(ctx, "UPDATE `group` SET name=:name WHERE id=:id", group) return err } @@ -124,7 +135,7 @@ func (r *groupRepository) UpdatePermissions(ctx context.Context, permission mode } func (r *groupRepository) Remove(ctx context.Context, group models.Group) error { - _, err := db.NamedExecContext(ctx, "DELETE FROM group WHERE id=:id LIMIT 1", group) + _, err := db.NamedExecContext(ctx, "DELETE FROM `group` WHERE id=:id LIMIT 1", group) return err } From b11f88851f0764c70b42a457497ae898a3fc83ff Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sat, 2 Mar 2019 12:03:51 +0100 Subject: [PATCH 4/6] GroupController: Added PATCH /api/group/permission/:id and DELETE /api/group/:id --- controllers/group-controller.go | 121 ++++++++++++++++++++++++++++ database/sqlite/group-repository.go | 2 +- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/controllers/group-controller.go b/controllers/group-controller.go index 45aa908..c8be545 100644 --- a/controllers/group-controller.go +++ b/controllers/group-controller.go @@ -201,6 +201,125 @@ func (c *GroupController) updateGroup(w http.ResponseWriter, r *http.Request) { respond.Data(w, group) } +// updateGroupPermission (`PUT/PATCH /:group_id/permission/:user_id`): Update a user's permission on a group. +func (c *GroupController) updateGroupPermission(w http.ResponseWriter, r *http.Request) { + user := models.UserFromContext(r.Context()) + + groupIDStr := mux.Vars(r)["group_id"] + groupID, err := strconv.ParseInt(groupIDStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The group id "+groupIDStr+" is not valid.") + return + } + + userIDStr := mux.Vars(r)["user_id"] + userID, err := strconv.ParseInt(userIDStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The user id "+userIDStr+" is not valid.") + return + } + + putData := struct { + Read *bool `json:"read"` + Write *bool `json:"write"` + Create *bool `json:"create"` + Delete *bool `json:"delete"` + Manage *bool `json:"manage"` + }{} + if err := json.NewDecoder(r.Body).Decode(&putData); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_json", "Input is not valid JSON.") + return + } + if putData.Manage != nil { + respond.Error(w, http.StatusBadRequest, "cannot_change_manage", "You cannot change the manage permission.") + return + } + + group, err := c.groups.FindByID(r.Context(), int(groupID)) + if err != nil { + respond.Error(w, http.StatusNotFound, "group_not_found", "The group could not be found.") + return + } else if !group.Permission(user.ID).Manage { + respond.Error(w, http.StatusForbidden, "permission_denied", "This transgression has been dispatched to the North Pole for review.") + return + } + + user2, err := c.users.FindByID(r.Context(), int(userID)) + if err == sql.ErrNoRows { + respond.Error(w, http.StatusNotFound, "user_not_Found", "The user could not be found.") + return + } else if err != nil { + httperr.Respond(w, err) + return + } + + permissions := group.Permission(user2.ID) + if putData.Read != nil { + permissions.Read = *putData.Read + } + if putData.Write != nil { + permissions.Write = *putData.Write + } + if putData.Create != nil { + permissions.Create = *putData.Create + } + if putData.Delete != nil { + permissions.Delete = *putData.Delete + } + + err = c.groups.UpdatePermissions(r.Context(), permissions) + if err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, permissions) +} + +// deleteGroup (`DELETE /:group_id`): Delete a group. +func (c *GroupController) deleteGroup(w http.ResponseWriter, r *http.Request) { + user := models.UserFromContext(r.Context()) + + groupIDStr := mux.Vars(r)["group_id"] + groupID, err := strconv.ParseInt(groupIDStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The group id "+groupIDStr+" is not valid.") + return + } + + group, err := c.groups.FindByID(r.Context(), int(groupID)) + if err != nil { + respond.Error(w, http.StatusNotFound, "group_not_found", "The group cannot be found.") + return + } else if !group.Permission(user.ID).Manage { + respond.Error(w, http.StatusNotFound, "permission_denied", "Your transgression will be remembered.") + return + } + + lights, err := c.lights.ListByGroup(r.Context(), group) + if err != nil && err != sql.ErrNoRows { + httperr.Respond(w, err) + return + } + + for _, light := range lights { + light.GroupID = 0 + err := c.lights.Update(r.Context(), light) + if err != nil { + httperr.Respond(w, err) + return + } + } + + err = c.groups.Remove(r.Context(), group) + if err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, group) +} + // Mount mounts the controller func (c *GroupController) Mount(router *mux.Router, prefix string) { sub := router.PathPrefix(prefix).Subrouter() @@ -209,6 +328,8 @@ func (c *GroupController) Mount(router *mux.Router, prefix string) { sub.HandleFunc("/", c.postGroup).Methods("POST") sub.HandleFunc("/{group_id}", c.getGroup).Methods("GET") sub.HandleFunc("/{group_id}", c.updateGroup).Methods("PUT", "PATCH") + sub.HandleFunc("/{group_id}", c.deleteGroup).Methods("DELETE") + sub.HandleFunc("/{group_id}/permission/{user_id}", c.updateGroupPermission).Methods("PUT", "PATCH") sub.HandleFunc("/{group_id}/light/", c.getGroupLights).Methods("GET") sub.HandleFunc("/{group_id}/light/{light_id}", c.getGroupLight).Methods("GET") } diff --git a/database/sqlite/group-repository.go b/database/sqlite/group-repository.go index ee75f80..1505bcd 100644 --- a/database/sqlite/group-repository.go +++ b/database/sqlite/group-repository.go @@ -135,7 +135,7 @@ func (r *groupRepository) UpdatePermissions(ctx context.Context, permission mode } func (r *groupRepository) Remove(ctx context.Context, group models.Group) error { - _, err := db.NamedExecContext(ctx, "DELETE FROM `group` WHERE id=:id LIMIT 1", group) + _, err := db.NamedExecContext(ctx, "DELETE FROM `group` WHERE id=:id", group) return err } From 4285ec4d7a78f258ce8ec8d055b660e1552da557 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sun, 3 Mar 2019 17:47:57 +0100 Subject: [PATCH 5/6] BridgeController: Added /api/bridge endpoints. --- cmd/lucifer-server/main.go | 3 +- controllers/bridge-controller.go | 220 +++++++++++++++++++++++++++ controllers/group-controller.go | 2 +- controllers/light-controller.go | 2 +- controllers/user-controller.go | 2 +- database/sqlite/bridge-repository.go | 4 +- internal/httperr/error.go | 3 + light/driver.go | 11 ++ light/hue/driver.go | 2 +- light/service.go | 79 ++++++---- 10 files changed, 292 insertions(+), 36 deletions(-) create mode 100644 controllers/bridge-controller.go diff --git a/cmd/lucifer-server/main.go b/cmd/lucifer-server/main.go index 142ddf2..2f4124d 100644 --- a/cmd/lucifer-server/main.go +++ b/cmd/lucifer-server/main.go @@ -45,6 +45,7 @@ func main() { userController := controllers.NewUserController(sqlite.UserRepository, sqlite.SessionRepository) groupController := controllers.NewGroupController(sqlite.GroupRepository, sqlite.UserRepository, sqlite.LightRepository) lightController := controllers.NewLightController(lightService, sqlite.GroupRepository, sqlite.UserRepository, sqlite.LightRepository) + bridgeController := controllers.NewBridgeController(lightService, sqlite.GroupRepository) // Router router := mux.NewRouter() @@ -52,10 +53,10 @@ func main() { groupController.Mount(router, "/api/group/") userController.Mount(router, "/api/user/") lightController.Mount(router, "/api/light/") + bridgeController.Mount(router, "/api/bridge/") // Background Tasks go lightService.SyncLoop(context.TODO()) - //go lightService.DiscoverLoop(context.TODO()) // TODO: Listen in another goroutine and have SIGINT/SIGTERM handlers with graceful shutdown. http.ListenAndServe(conf.Server.Address, router) diff --git a/controllers/bridge-controller.go b/controllers/bridge-controller.go new file mode 100644 index 0000000..c273954 --- /dev/null +++ b/controllers/bridge-controller.go @@ -0,0 +1,220 @@ +package controllers + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "strconv" + + "git.aiterp.net/lucifer/lucifer/internal/httperr" + "git.aiterp.net/lucifer/lucifer/internal/respond" + "git.aiterp.net/lucifer/lucifer/light" + "git.aiterp.net/lucifer/lucifer/models" + "github.com/gorilla/mux" +) + +// The BridgeController is a controller for all bridge things. +type BridgeController struct { + service *light.Service + groups models.GroupRepository +} + +// getBridges (`GET /`): Get all bridges +func (c *BridgeController) getBridges(w http.ResponseWriter, r *http.Request) { + bridges, err := c.service.Bridges(r.Context()) + if err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, bridges) +} + +// getBridge (`GET /:bridge_id`): Get bridge by ID +func (c *BridgeController) getBridge(w http.ResponseWriter, r *http.Request) { + idStr := mux.Vars(r)["bridge_id"] + id, err := strconv.ParseInt(idStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The id "+idStr+" is not valid.") + return + } + + bridge, err := c.service.Bridge(r.Context(), int(id)) + if err == sql.ErrNoRows { + respond.Error(w, 404, "bridge_not_found", "The bridge cannot be found.") + return + } else if err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, bridge) +} + +// postBridge (`POST /`): Post bridge +func (c *BridgeController) postBridge(w http.ResponseWriter, r *http.Request) { + postData := struct { + Name string `json:"name"` + Driver string `json:"driver"` + Addr string `json:"addr"` + }{} + if err := json.NewDecoder(r.Body).Decode(&postData); err != nil || postData.Name == "" || postData.Driver == "" || postData.Addr == "" { + respond.Error(w, http.StatusBadRequest, "invalid_json", "Input is not valid JSON.") + return + } + + bridge, err := c.service.DirectConnect(r.Context(), postData.Driver, postData.Addr, postData.Name) + if err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, bridge) +} + +// updateBridge (`PUT/PATCH /:bridge_id`): Update bridge by ID +func (c *BridgeController) updateBridge(w http.ResponseWriter, r *http.Request) { + idStr := mux.Vars(r)["bridge_id"] + id, err := strconv.ParseInt(idStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The id "+idStr+" is not valid.") + return + } + + putData := struct { + Name string + }{} + if err := json.NewDecoder(r.Body).Decode(&putData); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_json", "Input is not valid JSON.") + return + } + if putData.Name == "" { + respond.Error(w, http.StatusBadRequest, "invalid_name", "The name cannot be blank.") + return + } + + bridge, err := c.service.Bridge(r.Context(), int(id)) + if err == sql.ErrNoRows { + respond.Error(w, 404, "bridge_not_found", "The bridge cannot be found.") + return + } else if err != nil { + httperr.Respond(w, err) + return + } + + bridge.Name = putData.Name + err = c.service.UpdateBridge(r.Context(), bridge) + if err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, bridge) +} + +// postBridgeDiscover (`POST /:bridge_id/discover`): Delete bridge by ID +func (c *BridgeController) postBridgeDiscover(w http.ResponseWriter, r *http.Request) { + idStr := mux.Vars(r)["bridge_id"] + id, err := strconv.ParseInt(idStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The id "+idStr+" is not valid.") + return + } + + bridge, err := c.service.Bridge(r.Context(), int(id)) + if err == sql.ErrNoRows { + respond.Error(w, 404, "bridge_not_found", "The bridge cannot be found.") + return + } else if err != nil { + httperr.Respond(w, err) + return + } + + err = c.service.DiscoverLights(r.Context(), bridge) + if err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, bridge) +} + +// deleteBridge (`DELETE /:bridge_id`): Delete bridge by ID +func (c *BridgeController) deleteBridge(w http.ResponseWriter, r *http.Request) { + idStr := mux.Vars(r)["bridge_id"] + id, err := strconv.ParseInt(idStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The id "+idStr+" is not valid.") + return + } + + bridge, err := c.service.Bridge(r.Context(), int(id)) + if err == sql.ErrNoRows { + respond.Error(w, 404, "bridge_not_found", "The bridge cannot be found.") + return + } else if err != nil { + httperr.Respond(w, err) + return + } + + err = c.service.DeleteBridge(r.Context(), bridge) + if err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, bridge) +} + +// Mount mounts the controller +func (c *BridgeController) Mount(router *mux.Router, prefix string) { + sub := router.PathPrefix(prefix).Subrouter() + + sub.Use(c.adminMiddleware()) + + sub.HandleFunc("/", c.getBridges).Methods("GET") + sub.HandleFunc("/", c.postBridge).Methods("POST") + sub.HandleFunc("/{bridge_id}", c.getBridge).Methods("GET") + sub.HandleFunc("/{bridge_id}", c.updateBridge).Methods("PUT", "PATCH") + sub.HandleFunc("/{bridge_id}", c.deleteBridge).Methods("DELETE") + sub.HandleFunc("/{bridge_id}/discover", c.postBridgeDiscover).Methods("POST") +} + +func (c *BridgeController) adminMiddleware() mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := c.isAdmin(r.Context()) + if err != nil { + httperr.Respond(w, err) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func (c *BridgeController) isAdmin(ctx context.Context) error { + user := models.UserFromContext(ctx) + + lonelyLights, err := c.groups.FindByID(ctx, 0) + if err != nil { + return errors.New("Lonely Lights group could not be found") + } + + if !lonelyLights.Permission(user.ID).Write { + return httperr.ErrAccessDenied + } + + return nil +} + +// NewBridgeController makes a new bridge controller +func NewBridgeController(service *light.Service, groups models.GroupRepository) *BridgeController { + return &BridgeController{ + service: service, + groups: groups, + } +} diff --git a/controllers/group-controller.go b/controllers/group-controller.go index c8be545..7744d7f 100644 --- a/controllers/group-controller.go +++ b/controllers/group-controller.go @@ -15,7 +15,7 @@ import ( "github.com/gorilla/mux" ) -// The GroupController is a controller for all user inports. +// The GroupController is a controller for all group stuff. type GroupController struct { groups models.GroupRepository users models.UserRepository diff --git a/controllers/light-controller.go b/controllers/light-controller.go index a2cb5ae..42674b8 100644 --- a/controllers/light-controller.go +++ b/controllers/light-controller.go @@ -16,7 +16,7 @@ import ( "golang.org/x/sync/errgroup" ) -// The LightController is a controller for /api/light/. +// The LightController is a controller for light matters. type LightController struct { service *light.Service groups models.GroupRepository diff --git a/controllers/user-controller.go b/controllers/user-controller.go index ff95cde..9547437 100644 --- a/controllers/user-controller.go +++ b/controllers/user-controller.go @@ -12,7 +12,7 @@ import ( "github.com/gorilla/mux" ) -// The UserController is a controller for all user inports. +// The UserController is a controller for all users. type UserController struct { users models.UserRepository sessions models.SessionRepository diff --git a/database/sqlite/bridge-repository.go b/database/sqlite/bridge-repository.go index 3f6dc36..936809e 100644 --- a/database/sqlite/bridge-repository.go +++ b/database/sqlite/bridge-repository.go @@ -48,11 +48,11 @@ func (b *bridgeRepository) Insert(ctx context.Context, bridge models.Bridge) (mo } func (b *bridgeRepository) Update(ctx context.Context, bridge models.Bridge) error { - _, err := db.NamedExecContext(ctx, "UPDATE bridge SET name=:name AND addr=:addr AND key=:key WHERE id=:id", bridge) + _, err := db.NamedExecContext(ctx, "UPDATE bridge SET name=:name, addr=:addr, key=:key WHERE id=:id", bridge) return err } func (b *bridgeRepository) Remove(ctx context.Context, bridge models.Bridge) error { - _, err := db.NamedExecContext(ctx, "DELETE FROM bridge WHERE id=:id LIMIT 1", bridge) + _, err := db.NamedExecContext(ctx, "DELETE FROM bridge WHERE id=:id", bridge) return err } diff --git a/internal/httperr/error.go b/internal/httperr/error.go index fdbc8a8..5502f99 100644 --- a/internal/httperr/error.go +++ b/internal/httperr/error.go @@ -25,6 +25,9 @@ func (err Error) Error() string { // ErrLoginRequired is a common error for when a session is expected, but none is found. var ErrLoginRequired = Error{Status: 401, Kind: "login_required", Message: "You are not logged in."} +// ErrAccessDenied is a common error for when a session is expected, but none is found. +var ErrAccessDenied = Error{Status: 403, Kind: "access_denied", Message: "You cannot do that."} + // NotFound generates a 404 error. func NotFound(model string) Error { return Error{Status: 404, Kind: "not_found", Message: model + " not found"} diff --git a/light/driver.go b/light/driver.go index 9df45d9..698bcba 100644 --- a/light/driver.go +++ b/light/driver.go @@ -33,3 +33,14 @@ type Driver interface { func RegisterDriver(name string, driver Driver) { drivers[name] = driver } + +// Drivers gets the list of drivers. +func Drivers() []string { + results := make([]string, len(drivers)) + + for key := range drivers { + results = append(results, key) + } + + return results +} diff --git a/light/hue/driver.go b/light/hue/driver.go index f8eba9d..47cef7a 100644 --- a/light/hue/driver.go +++ b/light/hue/driver.go @@ -175,7 +175,7 @@ func (d *driver) Connect(ctx context.Context, bridge models.Bridge) (models.Brid } } - return models.Bridge{}, errors.New("Failed to create bridge") + return models.Bridge{}, errors.New("Bridge discovery timed out after 30 failed attempts") } func (d *driver) ChangedLights(ctx context.Context, bridge models.Bridge, lights ...models.Light) ([]models.Light, error) { diff --git a/light/service.go b/light/service.go index 44ef5b6..f197ee7 100644 --- a/light/service.go +++ b/light/service.go @@ -8,12 +8,11 @@ import ( "time" "git.aiterp.net/lucifer/lucifer/internal/httperr" - "git.aiterp.net/lucifer/lucifer/models" ) // ErrUnknownDriver is returned by any function asking for a driver name if the driver specified doesn't exist. -var ErrUnknownDriver = &httperr.Error{Status: http.StatusNotImplemented, Kind: "unknown_driver", Message: "Unknown light driver"} +var ErrUnknownDriver = httperr.Error{Status: http.StatusNotImplemented, Kind: "unknown_driver", Message: "Unknown light driver"} // A Service wraps the repos for lights and bridges and takes care of the business logic. type Service struct { @@ -39,7 +38,7 @@ func (s *Service) DirectConnect(ctx context.Context, driver string, addr string, bridge, err := d.Connect(ctx, bridge) if err != nil { - return models.Bridge{}, err + return models.Bridge{}, httperr.Error{Status: http.StatusPreconditionFailed, Kind: "connect_failed", Message: err.Error()} } bridge, err = s.bridges.Insert(ctx, bridge) @@ -83,6 +82,8 @@ func (s *Service) SyncLights(ctx context.Context, bridge models.Bridge) error { // Add unknown lights if it doesn't exist in the databse. if !found { log.Println("Adding unknown light", bridgeLight.InternalID) + bridgeLight.SetColor("FFFFFF") + bridgeLight.Brightness = 254 _, err := s.lights.Insert(ctx, bridgeLight) if err != nil { return err @@ -157,42 +158,62 @@ func (s *Service) SyncLoop(ctx context.Context) { } } -// DiscoverLoop discovers stuff. -func (s *Service) DiscoverLoop(ctx context.Context) { - for { - bridges, err := s.Bridges(ctx) - if err != nil { - log.Println("Failed to get bridges:", err) - continue - } +// Bridge gets a bridge by ID. +func (s *Service) Bridge(ctx context.Context, id int) (models.Bridge, error) { + return s.bridges.FindByID(ctx, id) +} - for _, bridge := range bridges { - d, ok := drivers[bridge.Driver] - if !ok { - continue - } +// Bridges gets all known bridges. +func (s *Service) Bridges(ctx context.Context) ([]models.Bridge, error) { + return s.bridges.List(ctx) +} - log.Println("Searcing on bridge", bridge.Name) +// DeleteBridge deletes the bridge. +func (s *Service) DeleteBridge(ctx context.Context, bridge models.Bridge) error { + s.mutex.Lock() + defer s.mutex.Unlock() - err := d.DiscoverLights(ctx, bridge) - if err != nil { - log.Println("Failed to discover lights:", err) - continue - } + err := s.bridges.Remove(ctx, bridge) + if err != nil { + return err + } - time.Sleep(time.Second * 60) + lights, err := s.lights.ListByBridge(ctx, bridge) + if err != nil { + return err + } + + for _, light := range lights { + err := s.lights.Remove(ctx, light) + if err != nil { + return err } } + + return nil } -// Bridge gets a bridge by ID. -func (s *Service) Bridge(ctx context.Context, id int) (models.Bridge, error) { - return s.bridges.FindByID(ctx, id) +// UpdateBridge updates a bridge. +func (s *Service) UpdateBridge(ctx context.Context, bridge models.Bridge) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + err := s.bridges.Update(ctx, bridge) + if err != nil { + return err + } + + return nil } -// Bridges gets all known bridges. -func (s *Service) Bridges(ctx context.Context) ([]models.Bridge, error) { - return s.bridges.List(ctx) +// DiscoverLights discovers new lights. +func (s *Service) DiscoverLights(ctx context.Context, bridge models.Bridge) error { + d, ok := drivers[bridge.Driver] + if !ok { + return ErrUnknownDriver + } + + return d.DiscoverLights(ctx, bridge) } // NewService creates a new light.Service. From e11ba33cab963115dc2f292366e40afbf73be1c8 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sun, 3 Mar 2019 19:12:41 +0100 Subject: [PATCH 6/6] BridgeController: Added DELETE /api/bridge/:bridge_id/light/:light_id --- controllers/bridge-controller.go | 45 ++++++++++++++++++++++++++++++++ light/driver.go | 3 +++ light/hue/driver.go | 22 +++++++++++++++- light/service.go | 29 ++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/controllers/bridge-controller.go b/controllers/bridge-controller.go index c273954..6b37182 100644 --- a/controllers/bridge-controller.go +++ b/controllers/bridge-controller.go @@ -168,6 +168,50 @@ func (c *BridgeController) deleteBridge(w http.ResponseWriter, r *http.Request) respond.Data(w, bridge) } +// deleteBridgeLight (`DELETE /:bridge_id/light/:light_id`): Delete bridge by ID +func (c *BridgeController) deleteBridgeLight(w http.ResponseWriter, r *http.Request) { + idStr := mux.Vars(r)["bridge_id"] + id, err := strconv.ParseInt(idStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The id "+idStr+" is not valid.") + return + } + + lightIDStr := mux.Vars(r)["light_id"] + lightID, err := strconv.ParseInt(lightIDStr, 10, 32) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid_id", "The id "+lightIDStr+" is not valid.") + return + } + + bridge, err := c.service.Bridge(r.Context(), int(id)) + if err == sql.ErrNoRows { + respond.Error(w, 404, "bridge_not_found", "The bridge cannot be found.") + return + } else if err != nil { + httperr.Respond(w, err) + return + } + + light, err := c.service.Light(r.Context(), int(lightID)) + if err != nil { + httperr.Respond(w, err) + return + } + if light.BridgeID != bridge.ID { + respond.Error(w, 404, "bridge_not_found", "The bridge cannot be found.") + return + } + + err = c.service.DeleteBridgeLight(r.Context(), bridge, light) + if err != nil { + httperr.Respond(w, err) + return + } + + respond.Data(w, bridge) +} + // Mount mounts the controller func (c *BridgeController) Mount(router *mux.Router, prefix string) { sub := router.PathPrefix(prefix).Subrouter() @@ -179,6 +223,7 @@ func (c *BridgeController) Mount(router *mux.Router, prefix string) { sub.HandleFunc("/{bridge_id}", c.getBridge).Methods("GET") sub.HandleFunc("/{bridge_id}", c.updateBridge).Methods("PUT", "PATCH") sub.HandleFunc("/{bridge_id}", c.deleteBridge).Methods("DELETE") + sub.HandleFunc("/{bridge_id}/light/{light_id}", c.deleteBridgeLight).Methods("DELETE") sub.HandleFunc("/{bridge_id}/discover", c.postBridgeDiscover).Methods("POST") } diff --git a/light/driver.go b/light/driver.go index 698bcba..f5a1d8f 100644 --- a/light/driver.go +++ b/light/driver.go @@ -25,6 +25,9 @@ type Driver interface { // DiscoverLights asks the bridge to start a search for new lights. DiscoverLights(ctx context.Context, bridge models.Bridge) error + // ForgetLight removes the light from the bridge. + ForgetLight(ctx context.Context, bridge models.Bridge, light models.Light) error + // ChangedLights returns a subset of the list describing which lights to update. ChangedLights(ctx context.Context, bridge models.Bridge, lights ...models.Light) ([]models.Light, error) } diff --git a/light/hue/driver.go b/light/hue/driver.go index 47cef7a..422a799 100644 --- a/light/hue/driver.go +++ b/light/hue/driver.go @@ -9,8 +9,8 @@ import ( "sync" "time" + "git.aiterp.net/lucifer/lucifer/internal/httperr" "git.aiterp.net/lucifer/lucifer/internal/huecolor" - "git.aiterp.net/lucifer/lucifer/light" "git.aiterp.net/lucifer/lucifer/models" gohue "github.com/collinux/gohue" @@ -212,6 +212,26 @@ func (d *driver) ChangedLights(ctx context.Context, bridge models.Bridge, lights return subset, nil } +func (d *driver) ForgetLight(ctx context.Context, bridge models.Bridge, light models.Light) error { + hueBridge, err := d.getBridge(bridge) + if err != nil { + return err + } + + hueLights, err := hueBridge.GetAllLights() + if err != nil { + return err + } + + for _, hueLight := range hueLights { + if light.InternalID == hueLight.UniqueID { + return hueLight.Delete() + } + } + + return httperr.NotFound("Light") +} + func (d *driver) calcColor(light models.Light, hueLight gohue.Light) (x, y float64, bri uint8, err error) { r, g, b, err := light.ColorRGBf() if err != nil { diff --git a/light/service.go b/light/service.go index f197ee7..8262ed3 100644 --- a/light/service.go +++ b/light/service.go @@ -84,6 +84,7 @@ func (s *Service) SyncLights(ctx context.Context, bridge models.Bridge) error { log.Println("Adding unknown light", bridgeLight.InternalID) bridgeLight.SetColor("FFFFFF") bridgeLight.Brightness = 254 + bridgeLight.On = true _, err := s.lights.Insert(ctx, bridgeLight) if err != nil { return err @@ -168,6 +169,11 @@ func (s *Service) Bridges(ctx context.Context) ([]models.Bridge, error) { return s.bridges.List(ctx) } +// Light gets all known bridges. +func (s *Service) Light(ctx context.Context, id int) (models.Light, error) { + return s.lights.FindByID(ctx, id) +} + // DeleteBridge deletes the bridge. func (s *Service) DeleteBridge(ctx context.Context, bridge models.Bridge) error { s.mutex.Lock() @@ -193,6 +199,29 @@ func (s *Service) DeleteBridge(ctx context.Context, bridge models.Bridge) error return nil } +// DeleteBridgeLight deletes the light in a bridge. +func (s *Service) DeleteBridgeLight(ctx context.Context, bridge models.Bridge, light models.Light) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + d, ok := drivers[bridge.Driver] + if !ok { + return ErrUnknownDriver + } + + err := d.ForgetLight(ctx, bridge, light) + if err != nil { + return err + } + + err = s.lights.Remove(ctx, light) + if err != nil { + return err + } + + return nil +} + // UpdateBridge updates a bridge. func (s *Service) UpdateBridge(ctx context.Context, bridge models.Bridge) error { s.mutex.Lock()