Functionally done!
This commit is contained in:
parent
426500df45
commit
7bcdd2dd80
10 changed files with 219 additions and 33 deletions
|
|
@ -11,14 +11,20 @@ import (
|
||||||
func main() {
|
func main() {
|
||||||
privateKeyPath := os.Getenv("CCCLIP_PRIVATE_KEY")
|
privateKeyPath := os.Getenv("CCCLIP_PRIVATE_KEY")
|
||||||
publicKeyPath := os.Getenv("CCCLIP_PUBLIC_KEY")
|
publicKeyPath := os.Getenv("CCCLIP_PUBLIC_KEY")
|
||||||
|
databaseLocation := os.Getenv("CCCLIP_DATABASE_LOCATION")
|
||||||
|
port := os.Getenv("CCCLIP_PORT")
|
||||||
|
|
||||||
if publicKeyPath == "" || privateKeyPath == "" {
|
if publicKeyPath == "" || privateKeyPath == "" {
|
||||||
log.Fatalf("both public and privae keys must be provided")
|
log.Fatalf("database location and public and privae keys must be provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Default.PrivateKeyPath = privateKeyPath
|
config.Default.PrivateKeyPath = privateKeyPath
|
||||||
config.Default.PublicKeyPath = publicKeyPath
|
config.Default.PublicKeyPath = publicKeyPath
|
||||||
|
config.Default.DatabaseLocation = databaseLocation
|
||||||
|
|
||||||
port := "8080"
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
log.Printf("Serving on port %s", port)
|
log.Printf("Serving on port %s", port)
|
||||||
s := server.New(":" + port)
|
s := server.New(":" + port)
|
||||||
log.Fatal(s.ListenAndServe())
|
log.Fatal(s.ListenAndServe())
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -13,6 +13,7 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.18 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
golang.org/x/sys v0.13.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -3,6 +3,8 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
||||||
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package config
|
||||||
type Config struct {
|
type Config struct {
|
||||||
PublicKeyPath string
|
PublicKeyPath string
|
||||||
PrivateKeyPath string
|
PrivateKeyPath string
|
||||||
|
DatabaseLocation string
|
||||||
}
|
}
|
||||||
|
|
||||||
var Default = Config{}
|
var Default = Config{}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import "github.com/AYM1607/ccclip/pkg/api"
|
||||||
|
|
||||||
type DB interface {
|
type DB interface {
|
||||||
// Users.
|
// Users.
|
||||||
PutUser(id string, passwordHash string) error
|
PutUser(id string, passwordHash []byte) error
|
||||||
GetUser(id string) (*api.User, error)
|
GetUser(id string) (*api.User, error)
|
||||||
|
|
||||||
// Devices.
|
// Devices.
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ func NewLocalDB() DB {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *localDB) PutUser(id, passwordHash string) error {
|
func (d *localDB) PutUser(id string, passwordHash []byte) error {
|
||||||
if _, ok := d.users[id]; ok {
|
if _, ok := d.users[id]; ok {
|
||||||
return errors.New("user exists")
|
return errors.New("user exists")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
182
internal/db/sqlite.go
Normal file
182
internal/db/sqlite.go
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/AYM1607/ccclip/pkg/api"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqliteDB struct {
|
||||||
|
internalDB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSQLiteDB(location string) DB {
|
||||||
|
internalDb, err := sql.Open("sqlite3", fmt.Sprintf("%s?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on", location))
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("could not connect to sqlite: %s", err.Error()))
|
||||||
|
}
|
||||||
|
db := &sqliteDB{
|
||||||
|
internalDB: internalDb,
|
||||||
|
}
|
||||||
|
if err := db.setup(); err != nil {
|
||||||
|
panic(fmt.Sprintf("unable to initialize sqlite: %s", err.Error()))
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sqliteDB) PutUser(id string, passwordHash []byte) error {
|
||||||
|
_, err := d.internalDB.Exec("INSERT INTO users(id, password_hash) values(?, ?)", id, passwordHash)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sqliteDB) GetUser(id string) (*api.User, error) {
|
||||||
|
res := &api.User{}
|
||||||
|
err := d.internalDB.QueryRow("SELECT id, password_hash FROM users WHERE id = ?", id).Scan(&res.ID, &res.PasswordHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sqliteDB) PutDevice(pubKey []byte, userId string) (string, error) {
|
||||||
|
id := ulid.Make().String()
|
||||||
|
_, err := d.internalDB.Exec("INSERT INTO devices(id, public_key, user_id) values(?, ? ,?)", id, pubKey, userId)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sqliteDB) GetDevice(id string) (*api.Device, error) {
|
||||||
|
res := &api.Device{}
|
||||||
|
err := d.internalDB.QueryRow("SELECT id, public_key FROM devices WHERE id = ?", id).Scan(&res.ID, &res.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sqliteDB) GetUserDevices(userId string) ([]*api.Device, error) {
|
||||||
|
res := []*api.Device{}
|
||||||
|
rows, err := d.internalDB.Query("SELECT id, public_key FROM devices WHERE user_id = ?", userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
device := &api.Device{}
|
||||||
|
err := rows.Scan(&device.ID, &device.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res = append(res, device)
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sqliteDB) GetDeviceUser(deviceId string) (*api.User, error) {
|
||||||
|
var userId string
|
||||||
|
err := d.internalDB.QueryRow("SELECT user_id FROM devices WHERE id = ?", deviceId).Scan(&userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := &api.User{}
|
||||||
|
err = d.internalDB.QueryRow("SELECT id, password_hash FROM users WHERE id = ?", userId).Scan(&res.ID, &res.PasswordHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sqliteDB) PutClipboard(userId string, clipboard *api.Clipboard) error {
|
||||||
|
tx, err := d.internalDB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
clipRes, err := tx.Exec("INSERT INTO clipboards(user_id, sender_public_key) values(?, ?)", userId, clipboard.SenderPublicKey)
|
||||||
|
clipId, err := clipRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for deviceId, ciphertext := range clipboard.Payloads {
|
||||||
|
_, err := tx.Exec("INSERT INTO clipboard_items(ciphertext, clipboard_id, device_id) values(?, ?, ?)", ciphertext, clipId, deviceId)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sqliteDB) GetClipboard(userId string) (*api.Clipboard, error) {
|
||||||
|
var latestClipId int
|
||||||
|
latestClip := &api.Clipboard{}
|
||||||
|
err := d.internalDB.QueryRow("SELECT id, sender_public_key FROM clipboards WHERE user_id = ? ORDER BY id DESC LIMIT 1", userId).Scan(&latestClipId, &latestClip.SenderPublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := d.internalDB.Query("SELECT device_id, ciphertext FROM clipboard_items WHERE clipboard_id = ?", latestClipId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
latestClip.Payloads = map[string][]byte{}
|
||||||
|
for rows.Next() {
|
||||||
|
var deviceId string
|
||||||
|
var cipherText []byte
|
||||||
|
err := rows.Scan(&deviceId, &cipherText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
latestClip.Payloads[deviceId] = cipherText
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return latestClip, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sqliteDB) setup() error {
|
||||||
|
setupStm := `
|
||||||
|
CREATE TABLE IF NOT EXISTS users(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
password_hash BLOB
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS devices(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
public_key BLOB,
|
||||||
|
user_id TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS clipboards(
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
sender_public_key BLOB,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS clipboard_items(
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ciphertext BLOB,
|
||||||
|
clipboard_id INTEGER,
|
||||||
|
device_id TEXT,
|
||||||
|
FOREIGN KEY(clipboard_id) REFERENCES clipboards(id),
|
||||||
|
FOREIGN KEY(device_id) REFERENCES devices(id)
|
||||||
|
) STRICT;
|
||||||
|
`
|
||||||
|
_, err := d.internalDB.Exec(setupStm)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -43,13 +42,6 @@ func (c *Client) Register(email, password string) error {
|
||||||
return errors.New("got unexpected response code from server")
|
return errors.New("got unexpected response code from server")
|
||||||
}
|
}
|
||||||
|
|
||||||
resBody, err := io.ReadAll(res.Body)
|
|
||||||
defer res.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Println(string(resBody))
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +69,6 @@ func (c *Client) RegisterDevice(email, password string, devicePublicKey []byte)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
log.Println(string(hresBody))
|
|
||||||
|
|
||||||
var res server.RegisterDeviceResponse
|
var res server.RegisterDeviceResponse
|
||||||
err = json.Unmarshal(hresBody, &res)
|
err = json.Unmarshal(hresBody, &res)
|
||||||
|
|
@ -122,12 +113,6 @@ func (c *Client) SetClipboard(plaintext string, deviceId string, pvk *ecdh.Priva
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hresBody, err := io.ReadAll(hres.Body)
|
|
||||||
defer hres.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Println(string(hresBody))
|
|
||||||
|
|
||||||
if hres.StatusCode != http.StatusOK {
|
if hres.StatusCode != http.StatusOK {
|
||||||
return errors.New("got unexpected response code from server")
|
return errors.New("got unexpected response code from server")
|
||||||
|
|
@ -165,7 +150,6 @@ func (c *Client) GetClipboard(deviceId string, pvk *ecdh.PrivateKey) (string, er
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
log.Println(string(hresBody))
|
|
||||||
|
|
||||||
if hres.StatusCode != http.StatusOK {
|
if hres.StatusCode != http.StatusOK {
|
||||||
return "", errors.New("got unexpected response code from server")
|
return "", errors.New("got unexpected response code from server")
|
||||||
|
|
@ -213,7 +197,6 @@ func (c *Client) getDevices(deviceId string, pvk *ecdh.PrivateKey) ([]*api.Devic
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
log.Println(string(hresBody))
|
|
||||||
|
|
||||||
if hres.StatusCode != http.StatusOK {
|
if hres.StatusCode != http.StatusOK {
|
||||||
return nil, errors.New("got unexpected response code from server")
|
return nil, errors.New("got unexpected response code from server")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"github.com/AYM1607/ccclip/internal/config"
|
"github.com/AYM1607/ccclip/internal/config"
|
||||||
"github.com/AYM1607/ccclip/internal/db"
|
"github.com/AYM1607/ccclip/internal/db"
|
||||||
|
|
@ -23,6 +24,8 @@ func New(addr string) *http.Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const minPasswordWork = 12
|
||||||
|
|
||||||
type controller struct {
|
type controller struct {
|
||||||
store db.DB
|
store db.DB
|
||||||
publicKey *ecdh.PublicKey
|
publicKey *ecdh.PublicKey
|
||||||
|
|
@ -42,8 +45,16 @@ func newHttpHandler() http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("could not load server's private key")
|
panic("could not load server's private key")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var store db.DB
|
||||||
|
if config.Default.DatabaseLocation == "" {
|
||||||
|
store = db.NewLocalDB()
|
||||||
|
} else {
|
||||||
|
store = db.NewSQLiteDB(config.Default.DatabaseLocation)
|
||||||
|
}
|
||||||
|
|
||||||
c := &controller{
|
c := &controller{
|
||||||
store: db.NewLocalDB(),
|
store: store,
|
||||||
publicKey: pbk,
|
publicKey: pbk,
|
||||||
privateKey: pvk,
|
privateKey: pvk,
|
||||||
}
|
}
|
||||||
|
|
@ -79,8 +90,13 @@ func (c *controller) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: This is obviously just for testing, use Bcrypt or similar for prod.
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), minPasswordWork)
|
||||||
err = c.store.PutUser(req.Email, req.Password)
|
if err != nil {
|
||||||
|
log.Printf("could not hash password: %s", err.Error())
|
||||||
|
http.Error(w, "password invalid", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.store.PutUser(req.Email, passwordHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -122,8 +138,7 @@ func (c *controller) handleRegisterDevice(w http.ResponseWriter, r *http.Request
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: This is obviously just for testing, use Bcrypt or similar for prod.
|
if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(req.Password)); err != nil {
|
||||||
if user.PasswordHash != req.Password {
|
|
||||||
http.Error(w, "password is not correct for the user", http.StatusUnauthorized)
|
http.Error(w, "password is not correct for the user", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -201,8 +216,6 @@ func (c *controller) handleSetClipboard(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("authReq: ", authReq)
|
|
||||||
|
|
||||||
req, err := decryptAuthenticatedPayload[*SetClipboardRequest](authReq, c.store, c.privateKey)
|
req, err := decryptAuthenticatedPayload[*SetClipboardRequest](authReq, c.store, c.privateKey)
|
||||||
// TODO: verify the request fingerprint. Right now we're just trusting that
|
// TODO: verify the request fingerprint. Right now we're just trusting that
|
||||||
// if it decrypts successfully then we can trust it.
|
// if it decrypts successfully then we can trust it.
|
||||||
|
|
@ -211,8 +224,6 @@ func (c *controller) handleSetClipboard(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("req: ", req)
|
|
||||||
|
|
||||||
user, err := c.store.GetDeviceUser(authReq.DeviceID)
|
user, err := c.store.GetDeviceUser(authReq.DeviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package api
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string
|
ID string
|
||||||
PasswordHash string
|
PasswordHash []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
type Device struct {
|
type Device struct {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue