diff --git a/config/config.exs b/config/config.exs index c658316..43b263a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -5,3 +5,14 @@ config :localiserd, Localiser.Repo, pool_size: 5 config :localiserd, ecto_repos: [Localiser.Repo] + +config :localiserd, Localiser.Web.Endpoint, + adapter: Bandit.PhoenixAdapter, + http: [port: 4000], + secret_key_base: System.get_env("SECRET_KEY_BASE") || + "localiser_dev_secret_key_base_change_in_prod_min64chars!!", + server: true, + render_errors: [formats: [json: Localiser.ErrorView], layout: false] + +config :localiserd, :jwt_secret, + System.get_env("JWT_SECRET") || "localiser_dev_jwt_secret_change_in_prod!!" diff --git a/lib/localiser/application.ex b/lib/localiser/application.ex index a5ea111..d0838f9 100644 --- a/lib/localiser/application.ex +++ b/lib/localiser/application.ex @@ -7,6 +7,7 @@ defmodule Localiser.Application do Localiser.Repo, {Registry, keys: :unique, name: Localiser.Registry}, {Phoenix.PubSub, name: Localiser.PubSub}, + Localiser.Web.Endpoint, Localiser.MQTT.Supervisor, Localiser.RSSI.Buffer, Localiser.Localisation.Floor.Manager, diff --git a/lib/localiser/domain/floors.ex b/lib/localiser/domain/floors.ex index ede7060..e7d56a4 100644 --- a/lib/localiser/domain/floors.ex +++ b/lib/localiser/domain/floors.ex @@ -9,6 +9,7 @@ defmodule Localiser.Domain.Floors do end def get_floor!(id), do: Repo.get!(Floor, id) + def any?, do: Repo.exists?(Floor) def create_floor(attrs) do case %Floor{} |> Floor.changeset(attrs) |> Repo.insert() do diff --git a/lib/localiser/domain/rooms.ex b/lib/localiser/domain/rooms.ex index 35cff87..1dd8f50 100644 --- a/lib/localiser/domain/rooms.ex +++ b/lib/localiser/domain/rooms.ex @@ -9,6 +9,8 @@ defmodule Localiser.Domain.Rooms do Repo.all(Room) end + def any?, do: Repo.exists?(Room) + def list_rooms_for_floor(floor_id) do Room |> where([r], r.floor_id == ^floor_id) diff --git a/lib/localiser/domain/schema/user.ex b/lib/localiser/domain/schema/user.ex index 36991c8..fe839b4 100644 --- a/lib/localiser/domain/schema/user.ex +++ b/lib/localiser/domain/schema/user.ex @@ -6,6 +6,7 @@ defmodule Localiser.Domain.Schema.User do field :username, :string field :password_hash, :string, redact: true field :password, :string, virtual: true, redact: true + field :is_admin, :boolean, default: false timestamps(type: :utc_datetime) end @@ -13,7 +14,7 @@ defmodule Localiser.Domain.Schema.User do @doc false def changeset(user, attrs) do user - |> cast(attrs, [:username, :password]) + |> cast(attrs, [:username, :password, :is_admin]) |> validate_required([:username, :password]) |> validate_length(:password, min: 8) |> unique_constraint(:username) diff --git a/lib/localiser/domain/sensors.ex b/lib/localiser/domain/sensors.ex index 02ce811..cdcbe84 100644 --- a/lib/localiser/domain/sensors.ex +++ b/lib/localiser/domain/sensors.ex @@ -9,6 +9,12 @@ defmodule Localiser.Domain.Sensors do Repo.all(Sensor) end + def list_unplaced do + Sensor |> where([s], is_nil(s.room_id)) |> Repo.all() + end + + def any_placed?, do: Repo.exists?(from s in Sensor, where: not is_nil(s.room_id)) + def list_sensors_for_room(room_id) do Sensor |> where([s], s.room_id == ^room_id) diff --git a/lib/localiser/domain/system.ex b/lib/localiser/domain/system.ex new file mode 100644 index 0000000..943ac60 --- /dev/null +++ b/lib/localiser/domain/system.ex @@ -0,0 +1,13 @@ +defmodule Localiser.Domain.System do + alias Localiser.Domain.{Floors, Rooms, Sensors, Tags, Users} + + def onboarding_status do + %{ + has_admin: Users.any?(), + has_floors: Floors.any?(), + has_rooms: Rooms.any?(), + has_sensors_placed: Sensors.any_placed?(), + has_tags: Tags.any?() + } + end +end diff --git a/lib/localiser/domain/tags.ex b/lib/localiser/domain/tags.ex index 38bb82a..6129398 100644 --- a/lib/localiser/domain/tags.ex +++ b/lib/localiser/domain/tags.ex @@ -6,6 +6,8 @@ defmodule Localiser.Domain.Tags do Repo.all(Tag) end + def any?, do: Repo.exists?(Tag) + def get_tag!(id), do: Repo.get!(Tag, id) def get_tag_by_tag_id(tag_id) do diff --git a/lib/localiser/domain/users.ex b/lib/localiser/domain/users.ex index 031e2ae..7933020 100644 --- a/lib/localiser/domain/users.ex +++ b/lib/localiser/domain/users.ex @@ -2,8 +2,19 @@ defmodule Localiser.Domain.Users do alias Localiser.Repo alias Localiser.Domain.Schema.User + def list_users, do: Repo.all(User) + def get_user!(id), do: Repo.get!(User, id) + def get_user(id) do + case Repo.get(User, id) do + nil -> {:error, :not_found} + user -> {:ok, user} + end + end + + def any?, do: Repo.exists?(User) + def get_user_by_username(username) do Repo.get_by(User, username: username) end @@ -14,6 +25,20 @@ defmodule Localiser.Domain.Users do |> Repo.insert() end + def update_user(%User{} = user, attrs) do + user + |> User.changeset(attrs) + |> Repo.update() + end + + def promote_to_admin(%User{} = user) do + user + |> Ecto.Changeset.change(is_admin: true) + |> Repo.update() + end + + def delete_user(%User{} = user), do: Repo.delete(user) + def authenticate_user(username, password) do user = get_user_by_username(username) diff --git a/lib/localiser/localisation/room/server.ex b/lib/localiser/localisation/room/server.ex index 3a99cf6..9f3ca24 100644 --- a/lib/localiser/localisation/room/server.ex +++ b/lib/localiser/localisation/room/server.ex @@ -81,10 +81,9 @@ defmodule Localiser.Localisation.Room.Server do def handle_info(_msg, state), do: {:noreply, state} defp broadcast(room_id, occupants) do - Phoenix.PubSub.broadcast( - @pubsub, - "room:#{room_id}", - {:room_occupancy_changed, room_id, occupants} - ) + msg = {:room_occupancy_changed, room_id, occupants} + + Phoenix.PubSub.broadcast(@pubsub, "room:#{room_id}", msg) + Phoenix.PubSub.broadcast(@pubsub, "rooms:occupancy", msg) end end diff --git a/lib/localiser/web/api_spec.ex b/lib/localiser/web/api_spec.ex new file mode 100644 index 0000000..6c2c005 --- /dev/null +++ b/lib/localiser/web/api_spec.ex @@ -0,0 +1,28 @@ +defmodule Localiser.Web.ApiSpec do + alias OpenApiSpex.{Components, Info, OpenApi, Paths, SecurityScheme, Server} + + @behaviour OpenApi + + @impl OpenApi + def spec do + %OpenApi{ + info: %Info{ + title: "localiserd API", + version: "1.0", + description: "BLE room-level localisation server REST API" + }, + servers: [%Server{url: "http://localhost:4000"}], + paths: Paths.from_router(Localiser.Web.Router), + components: %Components{ + securitySchemes: %{ + "bearerAuth" => %SecurityScheme{ + type: "http", + scheme: "bearer", + bearerFormat: "JWT" + } + } + } + } + |> OpenApiSpex.resolve_schema_modules() + end +end diff --git a/lib/localiser/web/channels/particles_channel.ex b/lib/localiser/web/channels/particles_channel.ex new file mode 100644 index 0000000..32fa522 --- /dev/null +++ b/lib/localiser/web/channels/particles_channel.ex @@ -0,0 +1,21 @@ +defmodule Localiser.Web.Channels.ParticlesChannel do + use Phoenix.Channel + + @impl true + def join("particles:" <> tag_id, _params, socket) do + Phoenix.PubSub.subscribe(Localiser.PubSub, "particles:#{tag_id}") + {:ok, assign(socket, :tag_id, tag_id)} + end + + @impl true + def handle_info({:particles_updated, payload}, socket) do + push(socket, "particles_updated", %{ + tag_id: payload.tag_id, + estimate: payload.estimate, + particles: payload.particles + }) + {:noreply, socket} + end + + def handle_info(_msg, socket), do: {:noreply, socket} +end diff --git a/lib/localiser/web/channels/room_channel.ex b/lib/localiser/web/channels/room_channel.ex new file mode 100644 index 0000000..5c1a21c --- /dev/null +++ b/lib/localiser/web/channels/room_channel.ex @@ -0,0 +1,20 @@ +defmodule Localiser.Web.Channels.RoomChannel do + use Phoenix.Channel + + @impl true + def join("rooms:occupancy", _params, socket) do + Phoenix.PubSub.subscribe(Localiser.PubSub, "rooms:occupancy") + {:ok, socket} + end + + @impl true + def handle_info({:room_occupancy_changed, room_id, occupants}, socket) do + push(socket, "occupancy_changed", %{ + room_id: room_id, + occupants: MapSet.to_list(occupants) + }) + {:noreply, socket} + end + + def handle_info(_msg, socket), do: {:noreply, socket} +end diff --git a/lib/localiser/web/channels/sensors_channel.ex b/lib/localiser/web/channels/sensors_channel.ex new file mode 100644 index 0000000..719d33b --- /dev/null +++ b/lib/localiser/web/channels/sensors_channel.ex @@ -0,0 +1,43 @@ +defmodule Localiser.Web.Channels.SensorsChannel do + use Phoenix.Channel + + @impl true + def join("sensors", _params, socket) do + Phoenix.PubSub.subscribe(Localiser.PubSub, "sensors") + {:ok, socket} + end + + @impl true + def handle_info({:sensor_announced, sensor}, socket) do + push(socket, "sensor_announced", render_sensor(sensor)) + {:noreply, socket} + end + + def handle_info({:sensor_enrolled, sensor}, socket) do + push(socket, "sensor_enrolled", render_sensor(sensor)) + {:noreply, socket} + end + + def handle_info({:sensor_unenrolled, sensor_id}, socket) do + push(socket, "sensor_unenrolled", %{sensor_id: sensor_id}) + {:noreply, socket} + end + + def handle_info({:calibration_complete, sensor_id}, socket) do + push(socket, "calibration_complete", %{sensor_id: sensor_id}) + {:noreply, socket} + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + defp render_sensor(sensor) do + %{ + id: sensor.id, + sensor_id: sensor.sensor_id, + room_id: sensor.room_id, + floor_x: sensor.floor_x, + floor_y: sensor.floor_y, + rssi_ref: sensor.rssi_ref + } + end +end diff --git a/lib/localiser/web/controllers/floor_controller.ex b/lib/localiser/web/controllers/floor_controller.ex new file mode 100644 index 0000000..cd7fcb6 --- /dev/null +++ b/lib/localiser/web/controllers/floor_controller.ex @@ -0,0 +1,113 @@ +defmodule Localiser.Web.Controllers.FloorController do + use Phoenix.Controller, formats: [:json] + use OpenApiSpex.ControllerSpecs + + alias Localiser.Domain.Floors + alias Localiser.Web.Schemas + + tags ["Floors"] + security [%{"bearerAuth" => []}] + + operation :index, + summary: "List all floors", + responses: [ + ok: {"Floor list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Floor}}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :show, + summary: "Get a floor", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + ok: {"Floor", "application/json", Schemas.Floor}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :create, + summary: "Create a floor", + request_body: {"Floor params", "application/json", Schemas.FloorParams, required: true}, + responses: [ + created: {"Created floor", "application/json", Schemas.Floor}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + operation :update, + summary: "Update a floor", + parameters: [id: [in: :path, type: :integer, required: true]], + request_body: {"Floor params", "application/json", Schemas.FloorUpdateParams}, + responses: [ + ok: {"Updated floor", "application/json", Schemas.Floor}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + operation :delete, + summary: "Delete a floor (also deletes all rooms and unenrols sensors)", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + no_content: "Deleted", + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + def index(conn, _params) do + json(conn, Enum.map(Floors.list_floors(), &render_floor/1)) + end + + def show(conn, %{"id" => id}) do + floor = Floors.get_floor!(id) + json(conn, render_floor(floor)) + end + + def create(conn, params) do + case Floors.create_floor(params) do + {:ok, floor} -> + conn + |> put_status(:created) + |> json(render_floor(floor)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def update(conn, %{"id" => id} = params) do + floor = Floors.get_floor!(id) + attrs = Map.drop(params, ["id"]) + + case Floors.update_floor(floor, attrs) do + {:ok, updated} -> + json(conn, render_floor(updated)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def delete(conn, %{"id" => id}) do + floor = Floors.get_floor!(id) + {:ok, _} = Floors.delete_floor(floor) + send_resp(conn, :no_content, "") + end + + defp render_floor(floor) do + %{ + id: floor.id, + name: floor.name, + width: floor.width, + height: floor.height + } + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, val}, acc -> + String.replace(acc, "%{#{key}}", to_string(val)) + end) + end) + end +end diff --git a/lib/localiser/web/controllers/onboarding_controller.ex b/lib/localiser/web/controllers/onboarding_controller.ex new file mode 100644 index 0000000..2c098fd --- /dev/null +++ b/lib/localiser/web/controllers/onboarding_controller.ex @@ -0,0 +1,20 @@ +defmodule Localiser.Web.Controllers.OnboardingController do + use Phoenix.Controller, formats: [:json] + use OpenApiSpex.ControllerSpecs + + alias Localiser.Domain.System + alias Localiser.Web.Schemas + + tags ["Onboarding"] + security [] + + operation :status, + summary: "Get onboarding checklist status", + responses: [ + ok: {"Onboarding status", "application/json", Schemas.OnboardingStatus} + ] + + def status(conn, _params) do + json(conn, System.onboarding_status()) + end +end diff --git a/lib/localiser/web/controllers/room_controller.ex b/lib/localiser/web/controllers/room_controller.ex new file mode 100644 index 0000000..50e6abf --- /dev/null +++ b/lib/localiser/web/controllers/room_controller.ex @@ -0,0 +1,130 @@ +defmodule Localiser.Web.Controllers.RoomController do + use Phoenix.Controller, formats: [:json] + use OpenApiSpex.ControllerSpecs + + alias Localiser.Domain.Rooms + alias Localiser.Web.Schemas + + tags ["Rooms"] + security [%{"bearerAuth" => []}] + + operation :index, + summary: "List rooms for a floor", + parameters: [floor_id: [in: :path, type: :integer, required: true]], + responses: [ + ok: {"Room list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Room}}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :show, + summary: "Get a room", + parameters: [ + floor_id: [in: :path, type: :integer, required: true], + id: [in: :path, type: :integer, required: true] + ], + responses: [ + ok: {"Room", "application/json", Schemas.Room}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :create, + summary: "Create a room", + parameters: [floor_id: [in: :path, type: :integer, required: true]], + request_body: {"Room params", "application/json", Schemas.RoomParams, required: true}, + responses: [ + created: {"Created room", "application/json", Schemas.Room}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + operation :update, + summary: "Update a room", + parameters: [ + floor_id: [in: :path, type: :integer, required: true], + id: [in: :path, type: :integer, required: true] + ], + request_body: {"Room params", "application/json", Schemas.RoomUpdateParams}, + responses: [ + ok: {"Updated room", "application/json", Schemas.Room}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + operation :delete, + summary: "Delete a room (also unenrols sensors)", + parameters: [ + floor_id: [in: :path, type: :integer, required: true], + id: [in: :path, type: :integer, required: true] + ], + responses: [ + no_content: "Deleted", + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + def index(conn, %{"floor_id" => floor_id}) do + rooms = Rooms.list_rooms_for_floor(floor_id) + json(conn, Enum.map(rooms, &render_room/1)) + end + + def show(conn, %{"id" => id}) do + room = Rooms.get_room!(id) + json(conn, render_room(room)) + end + + def create(conn, %{"floor_id" => floor_id} = params) do + attrs = Map.put(params, "floor_id", floor_id) + + case Rooms.create_room(attrs) do + {:ok, room} -> + conn + |> put_status(:created) + |> json(render_room(room)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def update(conn, %{"id" => id} = params) do + room = Rooms.get_room!(id) + attrs = Map.drop(params, ["id", "floor_id"]) + + case Rooms.update_room(room, attrs) do + {:ok, updated} -> + json(conn, render_room(updated)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def delete(conn, %{"id" => id}) do + room = Rooms.get_room!(id) + {:ok, _} = Rooms.delete_room(room) + send_resp(conn, :no_content, "") + end + + defp render_room(room) do + %{ + id: room.id, + name: room.name, + floor_id: room.floor_id, + x: room.x, + y: room.y, + width: room.width, + height: room.height + } + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, val}, acc -> + String.replace(acc, "%{#{key}}", to_string(val)) + end) + end) + end +end diff --git a/lib/localiser/web/controllers/sensor_controller.ex b/lib/localiser/web/controllers/sensor_controller.ex new file mode 100644 index 0000000..47bdba4 --- /dev/null +++ b/lib/localiser/web/controllers/sensor_controller.ex @@ -0,0 +1,193 @@ +defmodule Localiser.Web.Controllers.SensorController do + use Phoenix.Controller, formats: [:json] + use OpenApiSpex.ControllerSpecs + + alias Localiser.Domain.Sensors + alias Localiser.Localisation.Sensor.Server, as: SensorServer + alias Localiser.Web.Schemas + + tags ["Sensors"] + security [%{"bearerAuth" => []}] + + operation :index, + summary: "List all sensors", + responses: [ + ok: {"Sensor list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Sensor}}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :unplaced, + summary: "List sensors not yet assigned to a room", + responses: [ + ok: {"Sensor list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Sensor}}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :show, + summary: "Get a sensor", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + ok: {"Sensor", "application/json", Schemas.Sensor}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :update, + summary: "Update sensor metadata", + parameters: [id: [in: :path, type: :integer, required: true]], + request_body: {"Sensor params", "application/json", Schemas.SensorUpdateParams}, + responses: [ + ok: {"Updated sensor", "application/json", Schemas.Sensor}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + operation :delete, + summary: "Delete / unenrol a sensor", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + no_content: "Deleted", + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :place, + summary: "Place sensor at a position in a room", + parameters: [id: [in: :path, type: :integer, required: true]], + request_body: {"Place params", "application/json", Schemas.SensorPlaceParams, required: true}, + responses: [ + ok: {"Updated sensor", "application/json", Schemas.Sensor}, + bad_request: {"Missing params", "application/json", Schemas.Error}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + operation :unplace, + summary: "Remove sensor from floor layout", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + ok: {"Updated sensor", "application/json", Schemas.Sensor}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :calibration_start, + summary: "Begin RSSI calibration", + parameters: [id: [in: :path, type: :integer, required: true]], + request_body: {"Calibration params", "application/json", Schemas.CalibrationStartParams, required: true}, + responses: [ + ok: {"Calibration started", "application/json", Schemas.CalibrationStatus}, + bad_request: {"Missing reference_distance", "application/json", Schemas.Error}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :calibration_stop, + summary: "Abort active calibration", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + ok: {"Calibration aborted", "application/json", Schemas.CalibrationStatus}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + def index(conn, _params) do + json(conn, Enum.map(Sensors.list_sensors(), &render_sensor/1)) + end + + def unplaced(conn, _params) do + json(conn, Enum.map(Sensors.list_unplaced(), &render_sensor/1)) + end + + def show(conn, %{"id" => id}) do + sensor = Sensors.get_sensor!(id) + json(conn, render_sensor(sensor)) + end + + def update(conn, %{"id" => id} = params) do + sensor = Sensors.get_sensor!(id) + attrs = Map.drop(params, ["id"]) + + case Sensors.update_sensor(sensor, attrs) do + {:ok, updated} -> + json(conn, render_sensor(updated)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def delete(conn, %{"id" => id}) do + sensor = Sensors.get_sensor!(id) + {:ok, _} = Sensors.delete_sensor(sensor) + send_resp(conn, :no_content, "") + end + + def place(conn, %{"id" => id, "room_id" => room_id, "x" => x, "y" => y}) do + sensor = Sensors.get_sensor!(id) + + case Sensors.place_sensor(sensor, room_id, {x, y}) do + {:ok, updated} -> + json(conn, render_sensor(updated)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def place(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "room_id, x, and y are required"}) + end + + def unplace(conn, %{"id" => id}) do + sensor = Sensors.get_sensor!(id) + + case Sensors.remove_from_layout(sensor) do + {:ok, updated} -> + json(conn, render_sensor(updated)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def calibration_start(conn, %{"id" => id, "reference_distance" => ref_dist}) do + sensor = Sensors.get_sensor!(id) + :ok = SensorServer.begin_calibration(sensor.sensor_id, ref_dist) + json(conn, %{status: "calibrating"}) + end + + def calibration_start(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "reference_distance is required"}) + end + + def calibration_stop(conn, %{"id" => id}) do + sensor = Sensors.get_sensor!(id) + :ok = SensorServer.abort_calibration(sensor.sensor_id) + json(conn, %{status: "idle"}) + end + + defp render_sensor(sensor) do + %{ + id: sensor.id, + sensor_id: sensor.sensor_id, + room_id: sensor.room_id, + floor_x: sensor.floor_x, + floor_y: sensor.floor_y, + rssi_ref: sensor.rssi_ref + } + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, val}, acc -> + String.replace(acc, "%{#{key}}", to_string(val)) + end) + end) + end +end diff --git a/lib/localiser/web/controllers/session_controller.ex b/lib/localiser/web/controllers/session_controller.ex new file mode 100644 index 0000000..0229d21 --- /dev/null +++ b/lib/localiser/web/controllers/session_controller.ex @@ -0,0 +1,52 @@ +defmodule Localiser.Web.Controllers.SessionController do + use Phoenix.Controller, formats: [:json] + use OpenApiSpex.ControllerSpecs + + alias Localiser.Domain.Users + alias Localiser.Web.Token + alias Localiser.Web.Schemas + + tags ["Auth"] + security [] + + operation :create, + summary: "Log in and receive a JWT", + request_body: {"Login params", "application/json", Schemas.SessionParams, required: true}, + responses: [ + ok: {"JWT + user", "application/json", Schemas.TokenResponse}, + bad_request: {"Missing username/password", "application/json", Schemas.Error}, + unauthorized: {"Invalid credentials", "application/json", Schemas.Error} + ] + + operation :delete, + summary: "Log out (client discards token)", + responses: [no_content: "Logged out"] + + def create(conn, %{"username" => username, "password" => password}) do + case Users.authenticate_user(username, password) do + {:ok, user} -> + token = Token.generate(%{"sub" => user.id}) + json(conn, %{token: token, user: render_user(user)}) + + {:error, :invalid_credentials} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "invalid credentials"}) + end + end + + def create(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "username and password are required"}) + end + + def delete(conn, _params) do + # JWT is stateless; client discards token + send_resp(conn, :no_content, "") + end + + defp render_user(user) do + %{id: user.id, username: user.username, is_admin: user.is_admin} + end +end diff --git a/lib/localiser/web/controllers/setup_controller.ex b/lib/localiser/web/controllers/setup_controller.ex new file mode 100644 index 0000000..489ba1d --- /dev/null +++ b/lib/localiser/web/controllers/setup_controller.ex @@ -0,0 +1,57 @@ +defmodule Localiser.Web.Controllers.SetupController do + use Phoenix.Controller, formats: [:json] + use OpenApiSpex.ControllerSpecs + + alias Localiser.Domain.Users + alias Localiser.Web.Token + alias Localiser.Web.Schemas + + tags ["Auth"] + + operation :create, + summary: "Create first admin user", + description: "Only succeeds when no users exist. Blocked with 403 afterwards.", + security: [], + request_body: {"Setup params", "application/json", Schemas.SetupParams, required: true}, + responses: [ + created: {"Admin user created", "application/json", Schemas.TokenResponse}, + bad_request: {"Missing username/password", "application/json", Schemas.Error}, + forbidden: {"System already initialised", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + def create(conn, %{"username" => username, "password" => password}) do + attrs = %{"username" => username, "password" => password, "is_admin" => true} + + case Users.create_user(attrs) do + {:ok, user} -> + token = Token.generate(%{"sub" => user.id}) + conn + |> put_status(:created) + |> json(%{token: token, user: render_user(user)}) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def create(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "username and password are required"}) + end + + defp render_user(user) do + %{id: user.id, username: user.username, is_admin: user.is_admin} + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, val}, acc -> + String.replace(acc, "%{#{key}}", to_string(val)) + end) + end) + end +end diff --git a/lib/localiser/web/controllers/tag_controller.ex b/lib/localiser/web/controllers/tag_controller.ex new file mode 100644 index 0000000..3416649 --- /dev/null +++ b/lib/localiser/web/controllers/tag_controller.ex @@ -0,0 +1,112 @@ +defmodule Localiser.Web.Controllers.TagController do + use Phoenix.Controller, formats: [:json] + use OpenApiSpex.ControllerSpecs + + alias Localiser.Domain.Tags + alias Localiser.Web.Schemas + + tags ["Tags"] + security [%{"bearerAuth" => []}] + + operation :index, + summary: "List all tags", + responses: [ + ok: {"Tag list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Tag}}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :show, + summary: "Get a tag", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + ok: {"Tag", "application/json", Schemas.Tag}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :create, + summary: "Enroll a tag", + request_body: {"Tag params", "application/json", Schemas.TagParams, required: true}, + responses: [ + created: {"Enrolled tag", "application/json", Schemas.Tag}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + operation :update, + summary: "Update a tag", + parameters: [id: [in: :path, type: :integer, required: true]], + request_body: {"Tag params", "application/json", Schemas.TagUpdateParams}, + responses: [ + ok: {"Updated tag", "application/json", Schemas.Tag}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + operation :delete, + summary: "Delete / unenroll a tag", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + no_content: "Deleted", + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + def index(conn, _params) do + json(conn, Enum.map(Tags.list_tags(), &render_tag/1)) + end + + def show(conn, %{"id" => id}) do + tag = Tags.get_tag!(id) + json(conn, render_tag(tag)) + end + + def create(conn, params) do + case Tags.create_tag(params) do + {:ok, tag} -> + conn + |> put_status(:created) + |> json(render_tag(tag)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def update(conn, %{"id" => id} = params) do + tag = Tags.get_tag!(id) + attrs = Map.drop(params, ["id"]) + + case Tags.update_tag(tag, attrs) do + {:ok, updated} -> + json(conn, render_tag(updated)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def delete(conn, %{"id" => id}) do + tag = Tags.get_tag!(id) + {:ok, _} = Tags.delete_tag(tag) + send_resp(conn, :no_content, "") + end + + defp render_tag(tag) do + %{ + id: tag.id, + tag_id: tag.tag_id, + name: tag.name + } + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, val}, acc -> + String.replace(acc, "%{#{key}}", to_string(val)) + end) + end) + end +end diff --git a/lib/localiser/web/controllers/user_controller.ex b/lib/localiser/web/controllers/user_controller.ex new file mode 100644 index 0000000..049c8a3 --- /dev/null +++ b/lib/localiser/web/controllers/user_controller.ex @@ -0,0 +1,147 @@ +defmodule Localiser.Web.Controllers.UserController do + use Phoenix.Controller, formats: [:json] + use OpenApiSpex.ControllerSpecs + + alias Localiser.Domain.Users + alias Localiser.Web.Schemas + + tags ["Users"] + security [%{"bearerAuth" => []}] + + operation :me, + summary: "Get own profile", + responses: [ + ok: {"Current user", "application/json", Schemas.User}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error} + ] + + operation :index, + summary: "List all users (admin)", + responses: [ + ok: {"User list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.User}}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + forbidden: {"Forbidden", "application/json", Schemas.Error} + ] + + operation :show, + summary: "Get a user (admin)", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + ok: {"User", "application/json", Schemas.User}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + forbidden: {"Forbidden", "application/json", Schemas.Error} + ] + + operation :create, + summary: "Create a user (admin)", + request_body: {"User params", "application/json", Schemas.UserCreateParams, required: true}, + responses: [ + created: {"Created user", "application/json", Schemas.User}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + forbidden: {"Forbidden", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + operation :update, + summary: "Update a user (admin)", + parameters: [id: [in: :path, type: :integer, required: true]], + request_body: {"User params", "application/json", Schemas.UserUpdateParams}, + responses: [ + ok: {"Updated user", "application/json", Schemas.User}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + forbidden: {"Forbidden", "application/json", Schemas.Error}, + unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors} + ] + + operation :delete, + summary: "Delete a user (admin)", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + no_content: "Deleted", + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + forbidden: {"Forbidden", "application/json", Schemas.Error} + ] + + operation :promote, + summary: "Promote a user to admin", + parameters: [id: [in: :path, type: :integer, required: true]], + responses: [ + ok: {"Updated user", "application/json", Schemas.User}, + unauthorized: {"Unauthorized", "application/json", Schemas.Error}, + forbidden: {"Forbidden", "application/json", Schemas.Error} + ] + + def me(conn, _params) do + json(conn, render_user(conn.assigns.current_user)) + end + + def index(conn, _params) do + json(conn, Enum.map(Users.list_users(), &render_user/1)) + end + + def show(conn, %{"id" => id}) do + user = Users.get_user!(id) + json(conn, render_user(user)) + end + + def create(conn, params) do + case Users.create_user(params) do + {:ok, user} -> + conn + |> put_status(:created) + |> json(render_user(user)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def update(conn, %{"id" => id} = params) do + user = Users.get_user!(id) + attrs = Map.drop(params, ["id"]) + + case Users.update_user(user, attrs) do + {:ok, updated} -> + json(conn, render_user(updated)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + def delete(conn, %{"id" => id}) do + user = Users.get_user!(id) + {:ok, _} = Users.delete_user(user) + send_resp(conn, :no_content, "") + end + + def promote(conn, %{"id" => id}) do + user = Users.get_user!(id) + + case Users.promote_to_admin(user) do + {:ok, updated} -> + json(conn, render_user(updated)) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: format_errors(changeset)}) + end + end + + defp render_user(user) do + %{id: user.id, username: user.username, is_admin: user.is_admin} + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, val}, acc -> + String.replace(acc, "%{#{key}}", to_string(val)) + end) + end) + end +end diff --git a/lib/localiser/web/endpoint.ex b/lib/localiser/web/endpoint.ex new file mode 100644 index 0000000..7708b2f --- /dev/null +++ b/lib/localiser/web/endpoint.ex @@ -0,0 +1,17 @@ +defmodule Localiser.Web.Endpoint do + use Phoenix.Endpoint, otp_app: :localiserd + + socket "/socket", Localiser.Web.UserSocket, + websocket: true, + longpoll: false + + plug Plug.RequestId + plug Plug.Parsers, + parsers: [:json], + pass: ["application/json"], + json_decoder: Jason + + plug OpenApiSpex.Plug.PutApiSpec, module: Localiser.Web.ApiSpec + + plug Localiser.Web.Router +end diff --git a/lib/localiser/web/error_view.ex b/lib/localiser/web/error_view.ex new file mode 100644 index 0000000..2f487a3 --- /dev/null +++ b/lib/localiser/web/error_view.ex @@ -0,0 +1,6 @@ +defmodule Localiser.ErrorView do + def render("404.json", _assigns), do: %{error: "not found"} + def render("422.json", _assigns), do: %{error: "unprocessable entity"} + def render("500.json", _assigns), do: %{error: "internal server error"} + def render(_, _assigns), do: %{error: "error"} +end diff --git a/lib/localiser/web/plugs/admin_required.ex b/lib/localiser/web/plugs/admin_required.ex new file mode 100644 index 0000000..0f23321 --- /dev/null +++ b/lib/localiser/web/plugs/admin_required.ex @@ -0,0 +1,14 @@ +defmodule Localiser.Web.Plugs.AdminRequired do + import Plug.Conn + + def init(opts), do: opts + + def call(%{assigns: %{current_user: %{is_admin: true}}} = conn, _opts), do: conn + + def call(conn, _opts) do + conn + |> put_resp_content_type("application/json") + |> send_resp(403, ~s({"error":"admin required"})) + |> halt() + end +end diff --git a/lib/localiser/web/plugs/auth_required.ex b/lib/localiser/web/plugs/auth_required.ex new file mode 100644 index 0000000..37e810f --- /dev/null +++ b/lib/localiser/web/plugs/auth_required.ex @@ -0,0 +1,24 @@ +defmodule Localiser.Web.Plugs.AuthRequired do + import Plug.Conn + + alias Localiser.Web.Token + + def init(opts), do: opts + + def call(conn, _opts) do + with ["Bearer " <> token] <- get_req_header(conn, "authorization"), + {:ok, claims} <- Token.verify_token(token) do + assign(conn, :current_user, %{ + user_id: claims["user_id"], + username: claims["username"], + is_admin: claims["is_admin"] + }) + else + _ -> + conn + |> put_resp_content_type("application/json") + |> send_resp(401, ~s({"error":"unauthorised"})) + |> halt() + end + end +end diff --git a/lib/localiser/web/plugs/bootstrap_guard.ex b/lib/localiser/web/plugs/bootstrap_guard.ex new file mode 100644 index 0000000..aa3033e --- /dev/null +++ b/lib/localiser/web/plugs/bootstrap_guard.ex @@ -0,0 +1,19 @@ +defmodule Localiser.Web.Plugs.BootstrapGuard do + @moduledoc "Halts with 403 if any users already exist - protects POST /api/setup." + import Plug.Conn + + alias Localiser.Domain.Users + + def init(opts), do: opts + + def call(conn, _opts) do + if Users.any?() do + conn + |> put_resp_content_type("application/json") + |> send_resp(403, ~s({"error":"system already initialised"})) + |> halt() + else + conn + end + end +end diff --git a/lib/localiser/web/router.ex b/lib/localiser/web/router.ex new file mode 100644 index 0000000..30a7707 --- /dev/null +++ b/lib/localiser/web/router.ex @@ -0,0 +1,87 @@ +defmodule Localiser.Web.Router do + use Phoenix.Router, helpers: false + + alias Localiser.Web.Plugs.{AuthRequired, AdminRequired, BootstrapGuard} + + pipeline :api do + plug :accepts, ["json"] + end + + pipeline :authenticated do + plug :accepts, ["json"] + plug AuthRequired + end + + pipeline :admin do + plug :accepts, ["json"] + plug AuthRequired + plug AdminRequired + end + + pipeline :bootstrap do + plug :accepts, ["json"] + plug BootstrapGuard + end + + # OpenAPI spec (unauthenticated) + scope "/api" do + pipe_through :api + get "/openapi", OpenApiSpex.Plug.RenderSpec, [] + end + + # First-boot setup - forbidden once any user exists + scope "/api", Localiser.Web.Controllers do + pipe_through :bootstrap + post "/setup", SetupController, :create + end + + # Auth - public + scope "/api", Localiser.Web.Controllers do + pipe_through :api + post "/session", SessionController, :create + delete "/session", SessionController, :delete + end + + # Onboarding status - public + scope "/api", Localiser.Web.Controllers do + pipe_through :api + get "/onboarding", OnboardingController, :status + end + + # User self-service (show own profile) + scope "/api", Localiser.Web.Controllers do + pipe_through :authenticated + get "/users/me", UserController, :me + end + + # User admin CRUD + scope "/api", Localiser.Web.Controllers do + pipe_through :admin + get "/users", UserController, :index + get "/users/:id", UserController, :show + post "/users", UserController, :create + put "/users/:id", UserController, :update + delete "/users/:id", UserController, :delete + put "/users/:id/admin", UserController, :promote + end + + # Floors / Rooms / Tags - auth required + scope "/api", Localiser.Web.Controllers do + pipe_through :authenticated + + resources "/floors", FloorController, except: [:new, :edit] + resources "/floors/:floor_id/rooms", RoomController, except: [:new, :edit] + resources "/tags", TagController, except: [:new, :edit] + + # Sensors + get "/sensors", SensorController, :index + get "/sensors/unplaced", SensorController, :unplaced + get "/sensors/:id", SensorController, :show + put "/sensors/:id", SensorController, :update + delete "/sensors/:id", SensorController, :delete + put "/sensors/:id/place", SensorController, :place + delete "/sensors/:id/place", SensorController, :unplace + post "/sensors/:id/calibration/start", SensorController, :calibration_start + post "/sensors/:id/calibration/stop", SensorController, :calibration_stop + end +end diff --git a/lib/localiser/web/schemas.ex b/lib/localiser/web/schemas.ex new file mode 100644 index 0000000..ad44506 --- /dev/null +++ b/lib/localiser/web/schemas.ex @@ -0,0 +1,293 @@ +defmodule Localiser.Web.Schemas do + alias OpenApiSpex.Schema + + defmodule User do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "User", type: :object, + properties: %{ + id: %Schema{type: :integer}, + username: %Schema{type: :string}, + is_admin: %Schema{type: :boolean} + }, + required: [:id, :username, :is_admin] + }) + end + + defmodule Floor do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "Floor", type: :object, + properties: %{ + id: %Schema{type: :integer}, + name: %Schema{type: :string}, + width: %Schema{type: :number, format: :float}, + height: %Schema{type: :number, format: :float} + }, + required: [:id, :name, :width, :height] + }) + end + + defmodule Room do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "Room", type: :object, + properties: %{ + id: %Schema{type: :integer}, + name: %Schema{type: :string}, + floor_id: %Schema{type: :integer}, + x: %Schema{type: :number, format: :float}, + y: %Schema{type: :number, format: :float}, + width: %Schema{type: :number, format: :float}, + height: %Schema{type: :number, format: :float} + }, + required: [:id, :name, :floor_id, :x, :y, :width, :height] + }) + end + + defmodule Sensor do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "Sensor", type: :object, + properties: %{ + id: %Schema{type: :integer}, + sensor_id: %Schema{type: :string}, + room_id: %Schema{type: :integer, nullable: true}, + floor_x: %Schema{type: :number, format: :float, nullable: true}, + floor_y: %Schema{type: :number, format: :float, nullable: true}, + rssi_ref: %Schema{type: :number, format: :float, nullable: true} + }, + required: [:id, :sensor_id] + }) + end + + defmodule Tag do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "Tag", type: :object, + properties: %{ + id: %Schema{type: :integer}, + tag_id: %Schema{type: :string}, + name: %Schema{type: :string} + }, + required: [:id, :tag_id, :name] + }) + end + + defmodule OnboardingStatus do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "OnboardingStatus", type: :object, + properties: %{ + has_admin: %Schema{type: :boolean}, + has_floors: %Schema{type: :boolean}, + has_rooms: %Schema{type: :boolean}, + has_sensors_placed: %Schema{type: :boolean}, + has_tags: %Schema{type: :boolean} + }, + required: [:has_admin, :has_floors, :has_rooms, :has_sensors_placed, :has_tags] + }) + end + + defmodule TokenResponse do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "TokenResponse", type: :object, + properties: %{ + token: %Schema{type: :string}, + user: User + }, + required: [:token, :user] + }) + end + + defmodule Error do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "Error", type: :object, + properties: %{error: %Schema{type: :string}}, + required: [:error] + }) + end + + defmodule ValidationErrors do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "ValidationErrors", type: :object, + properties: %{ + errors: %Schema{ + type: :object, + additionalProperties: %Schema{type: :array, items: %Schema{type: :string}} + } + } + }) + end + + defmodule CalibrationStatus do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "CalibrationStatus", type: :object, + properties: %{status: %Schema{type: :string, enum: ["calibrating", "idle"]}}, + required: [:status] + }) + end + + # ── Request body schemas ──────────────────────────────────────────────────── + + defmodule SetupParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "SetupParams", type: :object, + properties: %{ + username: %Schema{type: :string}, + password: %Schema{type: :string, format: :password} + }, + required: [:username, :password] + }) + end + + defmodule SessionParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "SessionParams", type: :object, + properties: %{ + username: %Schema{type: :string}, + password: %Schema{type: :string, format: :password} + }, + required: [:username, :password] + }) + end + + defmodule UserCreateParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "UserCreateParams", type: :object, + properties: %{ + username: %Schema{type: :string}, + password: %Schema{type: :string, format: :password}, + is_admin: %Schema{type: :boolean, default: false} + }, + required: [:username, :password] + }) + end + + defmodule UserUpdateParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "UserUpdateParams", type: :object, + properties: %{ + username: %Schema{type: :string}, + password: %Schema{type: :string, format: :password} + } + }) + end + + defmodule FloorParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "FloorParams", type: :object, + properties: %{ + name: %Schema{type: :string}, + width: %Schema{type: :number, format: :float}, + height: %Schema{type: :number, format: :float} + }, + required: [:name, :width, :height] + }) + end + + defmodule FloorUpdateParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "FloorUpdateParams", type: :object, + properties: %{ + name: %Schema{type: :string}, + width: %Schema{type: :number, format: :float}, + height: %Schema{type: :number, format: :float} + } + }) + end + + defmodule RoomParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "RoomParams", type: :object, + properties: %{ + name: %Schema{type: :string}, + x: %Schema{type: :number, format: :float}, + y: %Schema{type: :number, format: :float}, + width: %Schema{type: :number, format: :float}, + height: %Schema{type: :number, format: :float} + }, + required: [:name, :x, :y, :width, :height] + }) + end + + defmodule RoomUpdateParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "RoomUpdateParams", type: :object, + properties: %{ + name: %Schema{type: :string}, + x: %Schema{type: :number, format: :float}, + y: %Schema{type: :number, format: :float}, + width: %Schema{type: :number, format: :float}, + height: %Schema{type: :number, format: :float} + } + }) + end + + defmodule SensorUpdateParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "SensorUpdateParams", type: :object, + properties: %{rssi_ref: %Schema{type: :number, format: :float}} + }) + end + + defmodule SensorPlaceParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "SensorPlaceParams", type: :object, + properties: %{ + room_id: %Schema{type: :integer}, + x: %Schema{type: :number, format: :float}, + y: %Schema{type: :number, format: :float} + }, + required: [:room_id, :x, :y] + }) + end + + defmodule CalibrationStartParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "CalibrationStartParams", type: :object, + properties: %{ + reference_distance: %Schema{ + type: :number, format: :float, + description: "Known tag-to-sensor distance in metres" + } + }, + required: [:reference_distance] + }) + end + + defmodule TagParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "TagParams", type: :object, + properties: %{ + tag_id: %Schema{type: :string, description: "BLE MAC address or device ID"}, + name: %Schema{type: :string} + }, + required: [:tag_id, :name] + }) + end + + defmodule TagUpdateParams do + require OpenApiSpex + OpenApiSpex.schema(%{ + title: "TagUpdateParams", type: :object, + properties: %{name: %Schema{type: :string}} + }) + end +end diff --git a/lib/localiser/web/token.ex b/lib/localiser/web/token.ex new file mode 100644 index 0000000..ab3d73c --- /dev/null +++ b/lib/localiser/web/token.ex @@ -0,0 +1,29 @@ +defmodule Localiser.Web.Token do + use Joken.Config + + @ttl 86_400 # 24 hours in seconds + + @impl true + def token_config do + default_claims( + iss: "localiserd", + default_exp: @ttl + ) + end + + def generate(claims) do + secret = Application.fetch_env!(:localiserd, :jwt_secret) + signer = Joken.Signer.create("HS256", secret) + + case generate_and_sign(claims, signer) do + {:ok, token, _claims} -> token + {:error, reason} -> raise "Token generation failed: #{inspect(reason)}" + end + end + + def verify_token(token) do + secret = Application.fetch_env!(:localiserd, :jwt_secret) + signer = Joken.Signer.create("HS256", secret) + verify_and_validate(token, signer) + end +end diff --git a/lib/localiser/web/user_socket.ex b/lib/localiser/web/user_socket.ex new file mode 100644 index 0000000..cbfd0ef --- /dev/null +++ b/lib/localiser/web/user_socket.ex @@ -0,0 +1,26 @@ +defmodule Localiser.Web.UserSocket do + use Phoenix.Socket + + channel "particles:*", Localiser.Web.Channels.ParticlesChannel + channel "rooms:occupancy", Localiser.Web.Channels.RoomChannel + channel "sensors", Localiser.Web.Channels.SensorsChannel + + @impl true + def connect(%{"token" => token}, socket, _connect_info) do + case Localiser.Web.Token.verify_token(token) do + {:ok, %{"sub" => user_id}} -> + case Localiser.Domain.Users.get_user(user_id) do + {:ok, user} -> {:ok, assign(socket, :current_user, user)} + _ -> :error + end + + {:error, _} -> + :error + end + end + + def connect(_params, _socket, _connect_info), do: :error + + @impl true + def id(socket), do: "user_socket:#{socket.assigns.current_user.id}" +end diff --git a/mix.exs b/mix.exs index 7310fcd..945138d 100644 --- a/mix.exs +++ b/mix.exs @@ -26,7 +26,11 @@ defmodule Localiserd.MixProject do {:jason, "~> 1.4"}, {:ecto_sqlite3, "~> 0.18"}, {:argon2_elixir, "~> 4.0"}, - {:phoenix_pubsub, "~> 2.1"} + {:phoenix_pubsub, "~> 2.1"}, + {:phoenix, "~> 1.7"}, + {:bandit, "~> 1.5"}, + {:joken, "~> 2.6"}, + {:open_api_spex, "~> 3.21"} ] end end diff --git a/mix.lock b/mix.lock index 3b13dfe..4976729 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"}, + "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, @@ -13,7 +14,19 @@ "exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"}, "getopt": {:hex, :getopt, "1.0.3", "4f3320c1f6f26b2bec0f6c6446b943eb927a1e6428ea279a1c6c534906ee79f1", [:rebar3], [], "hexpm", "7e01de90ac540f21494ff72792b1e3162d399966ebbfc674b4ce52cb8f49324f"}, "gun": {:hex, :gun, "2.1.0", "b4e4cbbf3026d21981c447e9e7ca856766046eff693720ba43114d7f5de36e87", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "52fc7fc246bfc3b00e01aea1c2854c70a366348574ab50c57dfe796d24a0101d"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "open_api_spex": {:hex, :open_api_spex, "3.22.2", "0b3c4f572ee69cb6c936abf426b9d84d8eebd34960871fd77aead746f0d69cb0", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "0a4fc08472d75e9cfe96e0748c6b1565b3b4398f97bf43fcce41b41b6fd3fb33"}, + "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, }