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 + + + + + + +
+
+

Lockserver

+
+
+
+

locks

+ +
+ +
+ + + 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() } }