diff --git a/.forgejo/workflows/docker-build.yaml b/.forgejo/workflows/docker-build.yaml
new file mode 100644
index 0000000..5a784b5
--- /dev/null
+++ b/.forgejo/workflows/docker-build.yaml
@@ -0,0 +1,19 @@
+on:
+ push:
+ branches:
+ - 'main'
+jobs:
+ build-container:
+ runs-on: docker
+ container:
+ image: library/docker:dind
+ steps:
+ - run: apk add --no-cache nodejs git
+ - name: login to container registry
+ run: echo "${{ secrets.PACKAGE_PUBLISH_TOKEN }}" | docker login --username finn --password-stdin git.janky.solutions
+ - name: build container
+ uses: docker/build-push-action@v4
+ with:
+ file: Containerfile
+ tags: git.janky.solutions/finn/lockserver:latest
+ push: true
diff --git a/.gitignore b/.gitignore
index 6dd20b3..2f0631f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
lockserver.db
+lockserver.json
diff --git a/Containerfile b/Containerfile
new file mode 100644
index 0000000..e20ca8b
--- /dev/null
+++ b/Containerfile
@@ -0,0 +1,24 @@
+# lockserver: manage z-wave locks
+# Copyright (C) 2024 Finn Herzfeld
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+FROM library/golang:1.21 AS build
+ADD . /go/lockserver
+WORKDIR /go/lockserver
+RUN CGO_ENABLED=0 go build .
+
+FROM scratch
+COPY --from=build /go/lockserver/lockserver /lockserver
+ENTRYPOINT ["/lockserver"]
diff --git a/cmd/lockserver/main.go b/cmd/lockserver/main.go
index d777b86..c5bfb9b 100644
--- a/cmd/lockserver/main.go
+++ b/cmd/lockserver/main.go
@@ -19,6 +19,11 @@ func main() {
func run() {
logrus.SetLevel(logrus.DebugLevel)
+
+ if err := config.Load(); err != nil {
+ logrus.WithError(err).Fatal("error loading config")
+ }
+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
diff --git a/config/config.go b/config/config.go
index 62d7f1a..4d10f51 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,13 +1,70 @@
package config
+import (
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "os"
+
+ "github.com/gorilla/securecookie"
+ "github.com/sirupsen/logrus"
+)
+
type Config struct {
- ZWaveJSServer string
- Database string
- HTTPBind string
+ ZWaveJSServer string `json:"zwave-js-server"`
+ SqliteDatabase string `json:"sqlite-database"`
+ HTTPBind string `json:"http-bind"`
+ SessionSecrets []JSONBytes `json:"session-secrets"`
}
var C = Config{
- ZWaveJSServer: "ws://home-assistant:3000",
- Database: "lockserver.db",
- HTTPBind: ":8080",
+ ZWaveJSServer: "ws://home-assistant:3000",
+ SqliteDatabase: "lockserver.db",
+ HTTPBind: ":8080",
+ SessionSecrets: []JSONBytes{},
+}
+
+func Load() error {
+ for _, path := range []string{"lockserver.json", "/etc/lockserver.json"} {
+ err := load(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ return err
+ }
+ logrus.WithField("file", path).Info("loaded config")
+ }
+
+ if len(C.SessionSecrets) == 0 {
+ logrus.WithFields(logrus.Fields{
+ "rand_64": base64.URLEncoding.EncodeToString(securecookie.GenerateRandomKey(64)),
+ "rand_32": base64.URLEncoding.EncodeToString(securecookie.GenerateRandomKey(32)),
+ }).Info("some potential session secrets for you (hint: use both)")
+ return errors.New("no session secrets defined, some possible values have been logged")
+ }
+
+ return nil
+}
+
+func load(path string) error {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ if err := json.NewDecoder(f).Decode(&C); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c Config) GetSessionSecrets() [][]byte {
+ var resp [][]byte
+ for _, s := range c.SessionSecrets {
+ resp = append(resp, s.AsByteArrayArray())
+ }
+ return resp
}
diff --git a/config/types.go b/config/types.go
new file mode 100644
index 0000000..f4dadd5
--- /dev/null
+++ b/config/types.go
@@ -0,0 +1,37 @@
+package config
+
+import (
+ "encoding/base64"
+ "encoding/json"
+)
+
+type JSONBytes []byte
+
+func (j JSONBytes) MarshalJSON() ([]byte, error) {
+ return json.Marshal(base64.URLEncoding.EncodeToString(j))
+}
+
+func (j JSONBytes) String() string {
+ return base64.URLEncoding.EncodeToString(j)
+}
+
+func (j *JSONBytes) UnmarshalJSON(data []byte) error {
+ var str string
+ err := json.Unmarshal(data, &str)
+ if err != nil {
+ return err
+ }
+
+ parsed, err := base64.URLEncoding.DecodeString(str)
+ if err != nil {
+ return err
+ }
+
+ *j = JSONBytes(parsed)
+
+ return nil
+}
+
+func (j JSONBytes) AsByteArrayArray() []byte {
+ return []byte(j)
+}
diff --git a/db/helpers.go b/db/helpers.go
index b803a3d..0a0ee40 100644
--- a/db/helpers.go
+++ b/db/helpers.go
@@ -1,6 +1,7 @@
package db
import (
+ "context"
"database/sql"
"embed"
@@ -12,7 +13,7 @@ import (
)
func Get() (*Queries, *sql.DB, error) {
- db, err := sql.Open("sqlite3", config.C.Database)
+ db, err := sql.Open("sqlite3", config.C.SqliteDatabase)
if err != nil {
return nil, nil, err
}
@@ -24,7 +25,7 @@ func Get() (*Queries, *sql.DB, error) {
var migrations embed.FS
func Migrate() error {
- logrus.WithField("dbfile", config.C.Database).Info("migrating database")
+ logrus.WithField("dbfile", config.C.SqliteDatabase).Info("migrating database")
_, conn, err := Get()
if err != nil {
@@ -51,3 +52,36 @@ func NullString(s string) sql.NullString {
String: s,
}
}
+
+type loggingDBTX struct {
+ Next DBTX
+}
+
+func (l loggingDBTX) ExecContext(ctx context.Context, query string, params ...interface{}) (sql.Result, error) {
+ logrus.WithFields(logrus.Fields{
+ "query": query,
+ "params": params,
+ }).Debug("ExecContext")
+ return l.Next.ExecContext(ctx, query, params...)
+}
+
+func (l loggingDBTX) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
+ logrus.WithFields(logrus.Fields{
+ "query": query,
+ }).Debug("PrepareContext")
+ return l.Next.PrepareContext(ctx, query)
+}
+func (l loggingDBTX) QueryContext(ctx context.Context, query string, params ...interface{}) (*sql.Rows, error) {
+ logrus.WithFields(logrus.Fields{
+ "query": query,
+ "params": params,
+ }).Debug("QueryContext")
+ return l.Next.QueryContext(ctx, query, params...)
+}
+func (l loggingDBTX) QueryRowContext(ctx context.Context, query string, params ...interface{}) *sql.Row {
+ logrus.WithFields(logrus.Fields{
+ "query": query,
+ "params": params,
+ }).Debug("QueryRowContext")
+ return l.Next.QueryRowContext(ctx, query, params...)
+}
diff --git a/db/migrations/1_init.sql b/db/migrations/1_init.sql
index 7654af4..6aca675 100644
--- a/db/migrations/1_init.sql
+++ b/db/migrations/1_init.sql
@@ -25,10 +25,35 @@ CREATE TABLE lock_log (
state TEXT NOT NULL,
code INTEGER REFERENCES lock_code_slots(id)
);
+
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL
+);
+
+CREATE TABLE user_codes (
+ id INTEGER PRIMARY KEY,
+ user INTEGER REFERENCES users(id),
+ name TEXT,
+ code TEXT UNIQUE NOT NULL,
+ start DATETIME,
+ end DATETIME
+);
+
+CREATE TABLE user_code_slots (
+ user_code REFERENCES user_codes(id),
+ lock REFERENCES locks(id),
+ slot REFERENCES lock_code_slots(id),
+
+ UNIQUE (user_code, lock)
+);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
+DROP TABLE user_code_slots;
+DROP TABLE user_codes;
+DROP TABLE users;
DROP TABLE lock_log;
DROP TABLE lock_code_slots;
DROP TABLE locks;
diff --git a/db/models.go b/db/models.go
index f540303..14f2e0d 100644
--- a/db/models.go
+++ b/db/models.go
@@ -30,3 +30,23 @@ type LockLog struct {
State string
Code sql.NullInt64
}
+
+type User struct {
+ ID int64
+ Name string
+}
+
+type UserCode struct {
+ ID int64
+ User sql.NullInt64
+ Name sql.NullString
+ Code string
+ Start sql.NullTime
+ End sql.NullTime
+}
+
+type UserCodeSlot struct {
+ UserCode interface{}
+ Lock interface{}
+ Slot interface{}
+}
diff --git a/db/queries/user_codes.sql b/db/queries/user_codes.sql
new file mode 100644
index 0000000..46baabe
--- /dev/null
+++ b/db/queries/user_codes.sql
@@ -0,0 +1,14 @@
+-- name: CreateUserCode :one
+INSERT INTO user_codes (user, code, start, end) VALUES (?, ?, ?, ?) RETURNING id;
+
+-- name: DeleteUserCode :exec
+DELETE FROM user_codes WHERE id = ?;
+
+-- name: AssignUserCodeSlot :exec
+INSERT INTO user_code_slots (user_code, lock, slot) VALUES (?, ?, ?);
+
+-- name: UnassignUserCodeSlot :exec
+DELETE FROM user_code_slots WHERE user_code = ? AND lock = ?;
+
+-- name: GetAllUserCodes :many
+SELECT user_codes.*, users.name FROM user_codes, users WHERE user_codes.user = users.id;
diff --git a/db/queries/users.sql b/db/queries/users.sql
new file mode 100644
index 0000000..6ca39e7
--- /dev/null
+++ b/db/queries/users.sql
@@ -0,0 +1,8 @@
+-- name: CreateUser :exec
+INSERT INTO users (name) VALUES (?);
+
+-- name: GetUserByName :one
+SELECT * FROM users WHERE name = ?;
+
+-- name: GetUserByID :one
+SELECT * FROM users WHERE id = ?;
diff --git a/db/user_codes.go b/db/user_codes.go
new file mode 100644
index 0000000..5b1aa0b
--- /dev/null
+++ b/db/user_codes.go
@@ -0,0 +1,27 @@
+package db
+
+import (
+ "time"
+
+ "git.janky.solutions/finn/lockserver/openapi"
+)
+
+func (u GetAllUserCodesRow) OpenAPI() openapi.UserCode {
+ resp := openapi.UserCode{Code: &u.Code}
+
+ if u.Name.Valid {
+ resp.User = &u.Name.String
+ }
+
+ if u.Start.Valid {
+ start := u.Start.Time.Format(time.RFC3339)
+ resp.Starts = &start
+ }
+
+ if u.End.Valid {
+ end := u.End.Time.Format(time.RFC3339)
+ resp.Ends = &end
+ }
+
+ return resp
+}
diff --git a/db/user_codes.sql.go b/db/user_codes.sql.go
new file mode 100644
index 0000000..f25d2a7
--- /dev/null
+++ b/db/user_codes.sql.go
@@ -0,0 +1,117 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.20.0
+// source: user_codes.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const assignUserCodeSlot = `-- name: AssignUserCodeSlot :exec
+INSERT INTO user_code_slots (user_code, lock, slot) VALUES (?, ?, ?)
+`
+
+type AssignUserCodeSlotParams struct {
+ UserCode interface{}
+ Lock interface{}
+ Slot interface{}
+}
+
+func (q *Queries) AssignUserCodeSlot(ctx context.Context, arg AssignUserCodeSlotParams) error {
+ _, err := q.db.ExecContext(ctx, assignUserCodeSlot, arg.UserCode, arg.Lock, arg.Slot)
+ return err
+}
+
+const createUserCode = `-- name: CreateUserCode :one
+INSERT INTO user_codes (user, code, start, end) VALUES (?, ?, ?, ?) RETURNING id
+`
+
+type CreateUserCodeParams struct {
+ User sql.NullInt64
+ Code string
+ Start sql.NullTime
+ End sql.NullTime
+}
+
+func (q *Queries) CreateUserCode(ctx context.Context, arg CreateUserCodeParams) (int64, error) {
+ row := q.db.QueryRowContext(ctx, createUserCode,
+ arg.User,
+ arg.Code,
+ arg.Start,
+ arg.End,
+ )
+ var id int64
+ err := row.Scan(&id)
+ return id, err
+}
+
+const deleteUserCode = `-- name: DeleteUserCode :exec
+DELETE FROM user_codes WHERE id = ?
+`
+
+func (q *Queries) DeleteUserCode(ctx context.Context, id int64) error {
+ _, err := q.db.ExecContext(ctx, deleteUserCode, id)
+ return err
+}
+
+const getAllUserCodes = `-- name: GetAllUserCodes :many
+SELECT user_codes.id, user_codes.user, user_codes.name, user_codes.code, user_codes.start, user_codes."end", users.name FROM user_codes, users WHERE user_codes.user = users.id
+`
+
+type GetAllUserCodesRow struct {
+ ID int64
+ User sql.NullInt64
+ Name sql.NullString
+ Code string
+ Start sql.NullTime
+ End sql.NullTime
+ Name_2 string
+}
+
+func (q *Queries) GetAllUserCodes(ctx context.Context) ([]GetAllUserCodesRow, error) {
+ rows, err := q.db.QueryContext(ctx, getAllUserCodes)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetAllUserCodesRow
+ for rows.Next() {
+ var i GetAllUserCodesRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.User,
+ &i.Name,
+ &i.Code,
+ &i.Start,
+ &i.End,
+ &i.Name_2,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const unassignUserCodeSlot = `-- name: UnassignUserCodeSlot :exec
+DELETE FROM user_code_slots WHERE user_code = ? AND lock = ?
+`
+
+type UnassignUserCodeSlotParams struct {
+ UserCode interface{}
+ Lock interface{}
+}
+
+func (q *Queries) UnassignUserCodeSlot(ctx context.Context, arg UnassignUserCodeSlotParams) error {
+ _, err := q.db.ExecContext(ctx, unassignUserCodeSlot, arg.UserCode, arg.Lock)
+ return err
+}
diff --git a/db/users.sql.go b/db/users.sql.go
new file mode 100644
index 0000000..2858d7f
--- /dev/null
+++ b/db/users.sql.go
@@ -0,0 +1,41 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.20.0
+// source: users.sql
+
+package db
+
+import (
+ "context"
+)
+
+const createUser = `-- name: CreateUser :exec
+INSERT INTO users (name) VALUES (?)
+`
+
+func (q *Queries) CreateUser(ctx context.Context, name string) error {
+ _, err := q.db.ExecContext(ctx, createUser, name)
+ return err
+}
+
+const getUserByID = `-- name: GetUserByID :one
+SELECT id, name FROM users WHERE id = ?
+`
+
+func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
+ row := q.db.QueryRowContext(ctx, getUserByID, id)
+ var i User
+ err := row.Scan(&i.ID, &i.Name)
+ return i, err
+}
+
+const getUserByName = `-- name: GetUserByName :one
+SELECT id, name FROM users WHERE name = ?
+`
+
+func (q *Queries) GetUserByName(ctx context.Context, name string) (User, error) {
+ row := q.db.QueryRowContext(ctx, getUserByName, name)
+ var i User
+ err := row.Scan(&i.ID, &i.Name)
+ return i, err
+}
diff --git a/frontend/frontend.go b/frontend/frontend.go
new file mode 100644
index 0000000..8ea3a13
--- /dev/null
+++ b/frontend/frontend.go
@@ -0,0 +1,30 @@
+package frontend
+
+import (
+ "embed"
+ "html/template"
+ "io/fs"
+)
+
+var (
+ //go:embed static
+ static embed.FS
+ Static fs.FS
+
+ //go:embed *.html
+ templatesFS embed.FS
+ Templates *template.Template
+)
+
+func init() {
+ var err error
+ Templates, err = template.ParseFS(templatesFS, "*")
+ if err != nil {
+ panic(err)
+ }
+
+ Static, err = fs.Sub(static, "static")
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..96a08e0
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+ Lock Server
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/static/main.css b/frontend/static/main.css
new file mode 100644
index 0000000..1db3f2a
--- /dev/null
+++ b/frontend/static/main.css
@@ -0,0 +1,12 @@
+html, body {
+ height: 100%;
+ margin: 0;
+}
+
+#wrapper {
+ min-height: calc(100% - 2em);
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ row-gap: 1em;
+ padding: 1em;
+}
diff --git a/go.mod b/go.mod
index 808ff02..25b2fcd 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,10 @@ module git.janky.solutions/finn/lockserver
go 1.21.8
require (
+ github.com/failsafe-go/failsafe-go v0.6.2
github.com/getkin/kin-openapi v0.124.0
github.com/google/uuid v1.6.0
+ github.com/gorilla/securecookie v1.1.2
github.com/gorilla/websocket v1.5.1
github.com/labstack/echo/v4 v4.11.4
github.com/mattn/go-sqlite3 v1.14.22
@@ -32,7 +34,7 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/net v0.20.0 // indirect
- golang.org/x/sync v0.6.0 // indirect
+ golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
index d948f9b..081c829 100644
--- a/go.sum
+++ b/go.sum
@@ -39,6 +39,8 @@ github.com/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn
github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ=
github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0=
github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss=
+github.com/failsafe-go/failsafe-go v0.6.2 h1:zRyfYykM080+h40uUuf9HYLRn7vpnR+wjcg68fhwD28=
+github.com/failsafe-go/failsafe-go v0.6.2/go.mod h1:UCRnPYTVzBt7QGPFAAmFZUtB49dCLVFt38YYzGHXBCA=
github.com/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M=
github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
@@ -60,10 +62,14 @@ github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOW
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -158,8 +164,8 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tursodatabase/libsql-client-go v0.0.0-20240220085343-4ae0eb9d0898 h1:1MvEhzI5pvP27e9Dzz861mxk9WzXZLSJwzOU67cKTbU=
github.com/tursodatabase/libsql-client-go v0.0.0-20240220085343-4ae0eb9d0898/go.mod h1:9bKuHS7eZh/0mJndbUOrCx8Ej3PlsRDszj4L7oVYMPQ=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
@@ -194,8 +200,8 @@ golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
-golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
-golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/httpserver/browser-endpoints.go b/httpserver/browser-endpoints.go
new file mode 100644
index 0000000..818cc5f
--- /dev/null
+++ b/httpserver/browser-endpoints.go
@@ -0,0 +1,44 @@
+package httpserver
+
+import (
+ "database/sql"
+ "errors"
+
+ echo "github.com/labstack/echo/v4"
+
+ "git.janky.solutions/finn/lockserver/db"
+ "git.janky.solutions/finn/lockserver/frontend"
+)
+
+type browserEndpoints struct{}
+
+type baseTemplateData struct {
+ Username string
+ UserDisplayName string
+}
+
+func (b browserEndpoints) Register(e *echo.Echo) {
+ e.GET("/", b.Index)
+ e.StaticFS("/static", frontend.Static)
+}
+
+type indexTemplateData struct {
+ baseTemplateData
+
+ Locks []db.Lock
+}
+
+func (browserEndpoints) Index(c echo.Context) error {
+ queries, dbc, err := db.Get()
+ if err != nil {
+ return err
+ }
+ defer dbc.Close()
+
+ locks, err := queries.GetLocks(c.Request().Context())
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+
+ return frontend.Templates.ExecuteTemplate(c.Response(), "index.html", indexTemplateData{Locks: locks})
+}
diff --git a/httpserver/server.go b/httpserver/server.go
index c87d7b5..2ed1e6b 100644
--- a/httpserver/server.go
+++ b/httpserver/server.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"net/http"
+ "time"
echo "github.com/labstack/echo/v4"
"github.com/sirupsen/logrus"
@@ -25,9 +26,13 @@ func ListenAndServe(client *zwavejs.Client) {
server.HideBanner = true
server.HidePort = true
server.HTTPErrorHandler = handleError
+ server.Use(accessLogMiddleware)
+
+ browserEndpoints{}.Register(server)
openapi.RegisterHandlersWithBaseURL(server, lockserver{ZWaveJS: client}, "/api")
+ logrus.WithField("address", config.C.HTTPBind).Info("starting http server")
err := server.Start(config.C.HTTPBind)
if err != http.ErrServerClosed {
logrus.WithError(err).Fatal("error starting http server")
@@ -40,7 +45,11 @@ func handleError(err error, c echo.Context) {
return
}
- logrus.WithError(err).Error("error handling request")
+ logrus.WithFields(logrus.Fields{
+ "path": c.Request().URL.Path,
+ "method": c.Request().Method,
+ "error": err,
+ }).Error("error handling request")
_ = c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
@@ -51,3 +60,24 @@ func Shutdown(ctx context.Context) error {
return server.Shutdown(ctx)
}
+
+func accessLogMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ start := time.Now()
+ err := next(c)
+
+ log := logrus.WithFields(logrus.Fields{
+ "method": c.Request().Method,
+ "path": c.Request().URL.Path,
+ "duration": time.Since(start),
+ "status": c.Response().Status,
+ "source": c.Request().RemoteAddr,
+ })
+ if err != nil {
+ log = log.WithError(err)
+ }
+ log.Info("request handled")
+
+ return err
+ }
+}
diff --git a/httpserver/user-codes-admin.go b/httpserver/user-codes-admin.go
new file mode 100644
index 0000000..09d751a
--- /dev/null
+++ b/httpserver/user-codes-admin.go
@@ -0,0 +1,51 @@
+package httpserver
+
+import (
+ "database/sql"
+ "errors"
+ "net/http"
+
+ "git.janky.solutions/finn/lockserver/db"
+ "git.janky.solutions/finn/lockserver/openapi"
+ echo "github.com/labstack/echo/v4"
+)
+
+func (l lockserver) AddUserCode(c echo.Context) error {
+ queries, dbc, err := db.Get()
+ if err != nil {
+ return err
+ }
+ defer dbc.Close()
+ ctx := c.Request().Context()
+
+ if _, err = queries.CreateUserCode(ctx, db.CreateUserCodeParams{}); err != nil {
+ return err
+ }
+
+ return c.NoContent(http.StatusInternalServerError)
+}
+
+func (l lockserver) GetAllUserCodes(c echo.Context) error {
+ queries, dbc, err := db.Get()
+ if err != nil {
+ return err
+ }
+ defer dbc.Close()
+ ctx := c.Request().Context()
+
+ resp := []openapi.UserCode{}
+
+ codes, err := queries.GetAllUserCodes(ctx)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return c.JSON(http.StatusOK, resp)
+ }
+ return err
+ }
+
+ for _, code := range codes {
+ resp = append(resp, code.OpenAPI())
+ }
+
+ return c.JSON(http.StatusOK, resp)
+}
diff --git a/lockserver.defaults.json b/lockserver.defaults.json
new file mode 100644
index 0000000..2af78c2
--- /dev/null
+++ b/lockserver.defaults.json
@@ -0,0 +1,6 @@
+{
+ "zwave-js-server": "ws://home-assistant:3000",
+ "sqlite-database": "lockserver.db",
+ "http-bind": ":8080",
+ "session-secrets": []
+}
diff --git a/make-defaults-json.go b/make-defaults-json.go
new file mode 100644
index 0000000..68b9d76
--- /dev/null
+++ b/make-defaults-json.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+
+ "git.janky.solutions/finn/lockserver/config"
+)
+
+func main() {
+ if err := writeConfig("lockserver.defaults.json"); err != nil {
+ panic(err)
+ }
+}
+
+func writeConfig(path string) error {
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ encoder := json.NewEncoder(f)
+ encoder.SetIndent("", " ")
+
+ if err := encoder.Encode(config.C); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/openapi/openapi.go b/openapi/openapi.go
index e148012..af53b53 100644
--- a/openapi/openapi.go
+++ b/openapi/openapi.go
@@ -27,12 +27,33 @@ type LockCodeSlot struct {
Enabled bool `json:"enabled"`
}
+// UserCode defines model for UserCode.
+type UserCode struct {
+ Code *string `json:"code,omitempty"`
+
+ // Ends when the code expires
+ Ends *string `json:"ends,omitempty"`
+
+ // Starts when the code becomes active
+ Starts *string `json:"starts,omitempty"`
+ User *string `json:"user,omitempty"`
+}
+
+// AddUserCodeJSONRequestBody defines body for AddUserCode for application/json ContentType.
+type AddUserCodeJSONRequestBody = UserCode
+
// PutLockCodeSlotJSONRequestBody defines body for PutLockCodeSlot for application/json ContentType.
type PutLockCodeSlotJSONRequestBody = LockCodeSlot
// ServerInterface represents all server handlers.
type ServerInterface interface {
+ // (GET /admin/user-codes)
+ GetAllUserCodes(ctx echo.Context) error
+
+ // (POST /admin/user-codes)
+ AddUserCode(ctx echo.Context) error
+
// (GET /locks/{lock}/slots/{slot})
GetLockCodeSlot(ctx echo.Context, lock int, slot int) error
@@ -45,6 +66,24 @@ type ServerInterfaceWrapper struct {
Handler ServerInterface
}
+// GetAllUserCodes converts echo context to params.
+func (w *ServerInterfaceWrapper) GetAllUserCodes(ctx echo.Context) error {
+ var err error
+
+ // Invoke the callback with all the unmarshaled arguments
+ err = w.Handler.GetAllUserCodes(ctx)
+ return err
+}
+
+// AddUserCode converts echo context to params.
+func (w *ServerInterfaceWrapper) AddUserCode(ctx echo.Context) error {
+ var err error
+
+ // Invoke the callback with all the unmarshaled arguments
+ err = w.Handler.AddUserCode(ctx)
+ return err
+}
+
// GetLockCodeSlot converts echo context to params.
func (w *ServerInterfaceWrapper) GetLockCodeSlot(ctx echo.Context) error {
var err error
@@ -121,11 +160,46 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
Handler: si,
}
+ router.GET(baseURL+"/admin/user-codes", wrapper.GetAllUserCodes)
+ router.POST(baseURL+"/admin/user-codes", wrapper.AddUserCode)
router.GET(baseURL+"/locks/:lock/slots/:slot", wrapper.GetLockCodeSlot)
router.PUT(baseURL+"/locks/:lock/slots/:slot", wrapper.PutLockCodeSlot)
}
+type GetAllUserCodesRequestObject struct {
+}
+
+type GetAllUserCodesResponseObject interface {
+ VisitGetAllUserCodesResponse(w http.ResponseWriter) error
+}
+
+type GetAllUserCodes200JSONResponse []UserCode
+
+func (response GetAllUserCodes200JSONResponse) VisitGetAllUserCodesResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type AddUserCodeRequestObject struct {
+ Body *AddUserCodeJSONRequestBody
+}
+
+type AddUserCodeResponseObject interface {
+ VisitAddUserCodeResponse(w http.ResponseWriter) error
+}
+
+type AddUserCode201JSONResponse UserCode
+
+func (response AddUserCode201JSONResponse) VisitAddUserCodeResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(201)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
type GetLockCodeSlotRequestObject struct {
Lock int `json:"lock"`
Slot int `json:"slot"`
@@ -157,6 +231,12 @@ type PutLockCodeSlotResponseObject interface {
// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
+ // (GET /admin/user-codes)
+ GetAllUserCodes(ctx context.Context, request GetAllUserCodesRequestObject) (GetAllUserCodesResponseObject, error)
+
+ // (POST /admin/user-codes)
+ AddUserCode(ctx context.Context, request AddUserCodeRequestObject) (AddUserCodeResponseObject, error)
+
// (GET /locks/{lock}/slots/{slot})
GetLockCodeSlot(ctx context.Context, request GetLockCodeSlotRequestObject) (GetLockCodeSlotResponseObject, error)
@@ -176,6 +256,58 @@ type strictHandler struct {
middlewares []StrictMiddlewareFunc
}
+// GetAllUserCodes operation middleware
+func (sh *strictHandler) GetAllUserCodes(ctx echo.Context) error {
+ var request GetAllUserCodesRequestObject
+
+ handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
+ return sh.ssi.GetAllUserCodes(ctx.Request().Context(), request.(GetAllUserCodesRequestObject))
+ }
+ for _, middleware := range sh.middlewares {
+ handler = middleware(handler, "GetAllUserCodes")
+ }
+
+ response, err := handler(ctx, request)
+
+ if err != nil {
+ return err
+ } else if validResponse, ok := response.(GetAllUserCodesResponseObject); ok {
+ return validResponse.VisitGetAllUserCodesResponse(ctx.Response())
+ } else if response != nil {
+ return fmt.Errorf("unexpected response type: %T", response)
+ }
+ return nil
+}
+
+// AddUserCode operation middleware
+func (sh *strictHandler) AddUserCode(ctx echo.Context) error {
+ var request AddUserCodeRequestObject
+
+ var body AddUserCodeJSONRequestBody
+ if err := ctx.Bind(&body); err != nil {
+ return err
+ }
+ request.Body = &body
+
+ handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
+ return sh.ssi.AddUserCode(ctx.Request().Context(), request.(AddUserCodeRequestObject))
+ }
+ for _, middleware := range sh.middlewares {
+ handler = middleware(handler, "AddUserCode")
+ }
+
+ response, err := handler(ctx, request)
+
+ if err != nil {
+ return err
+ } else if validResponse, ok := response.(AddUserCodeResponseObject); ok {
+ return validResponse.VisitAddUserCodeResponse(ctx.Response())
+ } else if response != nil {
+ return fmt.Errorf("unexpected response type: %T", response)
+ }
+ return nil
+}
+
// GetLockCodeSlot operation middleware
func (sh *strictHandler) GetLockCodeSlot(ctx echo.Context, lock int, slot int) error {
var request GetLockCodeSlotRequestObject
@@ -237,14 +369,16 @@ func (sh *strictHandler) PutLockCodeSlot(ctx echo.Context, lock int, slot int) e
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
- "H4sIAAAAAAAC/+SSsY7bMAyGX8VgOzqW296k7S4FigM6FLipONygyEyiqyzqJDpFYOjdCyo+JAGydOnS",
- "SbZI/qS+nzNYGiMFDJxBz5DtHkdTP7+T/bWmAZ88sfzHRBETO6xRSwPKyceIoCFzcmEHpQUMZuNxuIht",
- "iDyaAKW0kPBtcknCzyeJc8FL+15Am1e0DEUKXNiSaHlnMeTaMphRstbr1cPP1dP96q7roYUpedCwZ45Z",
- "K2UTGnYHtDSOFHJHaacWiaw2R3XX9UqmZcdexOSxGdMBE7RwwJQdBdDQd5+6XvIoYjDRgYYv9aqFaHhf",
- "SSgvtWqWo6jsibOa5SgS3WGFJ+gMOwqPA2j4hnxFV9SSGZExZdDPMwyYbXKRT1M8fm1o24g+CBDQtTm0",
- "7ySWyJktpwnbxcsLI1xg3GESI263EEeafJroRp8l8hd9XiQ7RxLskvC570/LExhDBWNi9M5WNOo1yyzz",
- "heDHhFvQ8EGdt1QtK6quCNZduX7SfSV28aaaE6cbfvyY/ic/3ibM/EDD8Z9ZwXtsAv5uDsZP2GwpNXJz",
- "c/xytTFh8r6UUv4EAAD///FFWy6nBAAA",
+ "H4sIAAAAAAAC/+RVO2/bMBD+K8S1o2w6bSZtjgsUAToUCDoUQQZaOttMKZI9npwahv57cZQcx4mQB9pO",
+ "nSiRvMf3OGkPVWhi8Og5QbmHVG2wMfnxS6h+LEKNVy6wvEcKEYkt5tMq1Cgr7yJCCYnJ+jV0BaA3S4f1",
+ "g7NlCA6Nh64rgPBna0mOr/sUx4Cb4hAQlrdYsST7lpAWQ6VX16/zhRpTRTayDR5KuNugV7xBJXEKf0VL",
+ "mKB4Gp7YEL+YYIlVaDApU7Hd4lieNiGN9Nc9ASlb1q+CXHa2Qp8yLm8aubVYTC6+T67mk/PpDApoyUEJ",
+ "G+aYSq0rQiP1q9A0wadpoLUeUiS93Onz6UxLL2zZSTJRNCFtkaCALVLqoc2mZ9OZ3AsRvYkWSviYtwqI",
+ "hjeZDG3qxnotqCbCQN5cYzaGyGKEpssaSviMPHfuIJxwTJhikJ7k8ofZrJfPM/ocbmJ0tsoJ9G2Sjg42",
+ "lCfL2OTA94QrKOGdPhpWD27V9zY50muIzK5n91RI45yqWiL0rIyv1arlllAJMtUjy0ExpBFw87q+r9Wb",
+ "GRNfhHr3Jkyvg9L183LC3dk/qfOYonsu1J1JKrsM66Eh7cREei9Lp5MLnPRelu45R5x8S8RWZBpkpATl",
+ "9eNJu/ykwkpJfpDJgDK7EIrDSAwnxy8JU4vFA9iDA6xnXCPJzI2XyAhT39FIneHkDXVu/tDrz+l1wuCI",
+ "ZvPM2ANMvYvbET2+tv+THn9/RF+SQv4SHu/U1rgW1SpQ/m+Mtn864b51TqbsdwAAAP//L1q9IJUHAAA=",
}
// GetSwagger returns the content of the embedded swagger specification file
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 5cc7ba6..fe2e4a6 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -51,6 +51,32 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/LockCodeSlot'
+ /admin/user-codes:
+ get:
+ operationId: get_all_user_codes
+ responses:
+ '200':
+ description: all current and future user codes
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/UserCode'
+ post:
+ operationId: add_user_code
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserCode'
+ responses:
+ '201':
+ description: user code was created
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserCode'
components:
schemas:
LockCodeSlot:
@@ -63,3 +89,16 @@ components:
type: string
enabled:
type: boolean
+ UserCode:
+ type: object
+ properties:
+ code:
+ type: string
+ user:
+ type: string
+ starts:
+ description: when the code becomes active
+ type: string
+ ends:
+ description: when the code expires
+ type: string
diff --git a/zwavejs/client.go b/zwavejs/client.go
index 3dea68a..ae155ee 100644
--- a/zwavejs/client.go
+++ b/zwavejs/client.go
@@ -6,13 +6,17 @@ import (
"encoding/json"
"errors"
"fmt"
+ "net"
"sync"
"time"
- "git.janky.solutions/finn/lockserver/db"
+ "github.com/failsafe-go/failsafe-go"
+ "github.com/failsafe-go/failsafe-go/retrypolicy"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/sirupsen/logrus"
+
+ "git.janky.solutions/finn/lockserver/db"
)
type Client struct {
@@ -32,22 +36,32 @@ func New(server string) (*Client, error) {
}
func (c *Client) DialAndListen(ctx context.Context) {
+ // Retry on ErrConnecting up to 3 times with a 1 second delay between attempts
+ connectRetryPolicy := retrypolicy.Builder[*websocket.Conn]().
+ WithBackoff(time.Second, time.Minute).
+ Build()
+
for {
logrus.WithField("server", c.Server).Info("connecting to zwave-js server")
- conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.Server, nil)
+ conn, err := failsafe.Get(func() (*websocket.Conn, error) {
+ conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.Server, nil)
+ return conn, err
+ }, connectRetryPolicy)
if err != nil {
- logrus.WithError(err).Error("error connecting to zwavejs server")
- time.Sleep(time.Second * 10)
- continue
+ logrus.WithError(err).Fatal("error connecting to zwavejs server")
}
c.conn = conn
logrus.Info("connected to zwave-js server")
if err := c.listen(ctx); err != nil {
+ if errors.Is(err, net.ErrClosed) {
+ return
+ }
+
logrus.WithError(err).Error("error communicating with zwavejs server")
- time.Sleep(time.Second * 10)
continue
}
+ _ = c.conn.Close()
}
}