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.