api.spaceplanner.app

Spaceplanner API
git clone git://jacobedwards.org/api.spaceplanner.app
Log | Files | Refs

commit 801183b1ad3b2984a19f05dbff20d606a0bb826f
parent 0cc7e62609eb47f85ec919efe886a5d58a5f6d63
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date:   Tue, 30 Jul 2024 16:19:07 -0700

Add user settings

Also support FastCGI.

Diffstat:
Mcmd/api/Makefile | 33++++++++++++++++++++++++++++-----
Mcmd/api/main.go | 23+++++++++++++++++++++--
Acmd/api/migration/2024-07-27T05:11:12.sql | 13+++++++++++++
Acmd/api/migration/2024-07-27T19:08:01.sql | 3+++
Acmd/api/scripts/newmigration | 3+++
Mcmd/api/users.go | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/backend/env.go | 8++++----
Minternal/backend/user.go | 146++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
8 files changed, 305 insertions(+), 16 deletions(-)

diff --git a/cmd/api/Makefile b/cmd/api/Makefile @@ -1,18 +1,41 @@ name = api.spaceplanner.app +prog = api # Database dbname = ${name} dbdir = /var/postgresql/data -dbuser = _postgresql +dbuser = _spaceplanner dburl = postgresql://${dbuser}@localhost/${dbname} dbsql = migration dbbackups = backups +# Server +srvuser = _spaceplanner +srvroot = /var/www +srvdir = /htdocs/api.spaceplanner.app +srvsock = ${srvroot}/run/api.spaceplanner.app.sock +srvsockuser = www + all: ${name} ${name}: go build +# I would use ${name} as dependancy, but since this must be run as +# root and I offload everything to go(1), this isn't what I want. +run: + install -o ${srvuser} -g ${srvuser} -m 0700 ${prog} ${srvroot}/${srvdir}/${prog} + kfcgi -dv -n 1 -N 1 \ + -s ${srvsock} -u ${srvsockuser} \ + -U ${srvuser} \ + -p ${srvroot} ${srvdir}/${prog} + +test-run: ${name} + go run . localhost:8888 + +clean: + rm -f ${name} + db: createdb updatedb createdb: @@ -23,12 +46,12 @@ createdb: # (${dbname}) updatedb: backupdb for f in $$(./scripts/dbupdates ${dbsql}); do \ - psql ${dburl} < ${dbsql}/$$f || { \ + psql -f "${dbsql}/$$f" ${dburl} ${dbuser} || { \ echo "FAILED TO APPLY $$f" ; \ exit 1 ; \ - }; \ + } ; \ echo "APPLIED $$f" ; \ - echo ${dbsql}/"$ff" > ${dbsql}/last ; \ + echo ${dbsql}/"$$f" > ${dbsql}/last ; \ done backupdb: @@ -44,4 +67,4 @@ restoredb: backupdb editdb: psql ${dburl} -.PHONY: all db createdb updatedb backupdb restoredb editdb +.PHONY: all run clean db createdb updatedb backupdb restoredb editdb diff --git a/cmd/api/main.go b/cmd/api/main.go @@ -4,6 +4,8 @@ import ( "database/sql" "log" "net/http" + "net/http/fcgi" + "os" "github.com/gin-gonic/gin" _ "github.com/lib/pq" @@ -11,7 +13,7 @@ import ( ) func main() { - env, err := initEnv("postgres://_postgresql@localhost/test?sslmode=disable") + env, err := initEnv("postgres://_spaceplanner@localhost/api.spaceplanner.app?sslmode=disable") if err != nil { log.Fatalf("Unable to initialize environment: %s", err.Error()) } @@ -31,7 +33,17 @@ func main() { engine.NoRoute(noRoute) engine.Use(AuthMiddleware(auth)) setRoutes(env, engine.Group("/v0"), auth) - engine.Run("localhost:8888") + + args := os.Args[1:] + if len(args) > 1 { + log.Fatal("Too many arguments") + } + + if len(args) == 0 { + fcgi.Serve(nil, engine) + } else { + engine.Run(args[0]) + } } func initEnv(database string) (*Env, error) { @@ -63,6 +75,13 @@ func setRoutes(env *Env, r *gin.RouterGroup, auth *jwt.GinJWTMiddleware) { func setAuthenticatedRoutes(env *Env, r *gin.RouterGroup) { r.DELETE("/users/:user", env.DeleteUser) + + user := r.Group("/users/:user/") + user.PUT("/settings", env.SetUserSettings) + user.PUT("/settings/:setting", env.SetUserSetting) + user.GET("/settings", env.GetUserSettings) + user.GET("/settings/:setting", env.GetUserSetting) + } func noRoute(c *gin.Context) { diff --git a/cmd/api/migration/2024-07-27T05:11:12.sql b/cmd/api/migration/2024-07-27T05:11:12.sql @@ -0,0 +1,13 @@ +CREATE TABLE user_settings ( + username varchar(128), + name varchar, + strval varchar, + numval int, + boolval bool, + PRIMARY KEY (username, name), + CONSTRAINT one_value_type CHECK ( + (strval IS NOT NULL)::int + + (numval IS NOT NULL)::int + + (boolval IS NOT NULL)::int = 1 + ) +); diff --git a/cmd/api/migration/2024-07-27T19:08:01.sql b/cmd/api/migration/2024-07-27T19:08:01.sql @@ -0,0 +1,3 @@ +CREATE SCHEMA spaceplanner ; +ALTER TABLE users SET SCHEMA spaceplanner ; +ALTER TABLE user_settings SET SCHEMA spaceplanner ; diff --git a/cmd/api/scripts/newmigration b/cmd/api/scripts/newmigration @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "$1"/"$(date -u +%FT%T)".sql diff --git a/cmd/api/users.go b/cmd/api/users.go @@ -49,3 +49,95 @@ func (e *Env) GetUser(c *gin.Context) { Respond(c, http.StatusOK, user) } + +func (e *Env) GetUserSettings(c *gin.Context) { + user := c.Param("user") + if user == "" { + RespondError(c, http.StatusNotFound, "No username given") + return + } + + settings, err := e.backend.GetUserSettings(user) + if err != nil { + RespondError(c, 400, "Unable to get settings: %s", err.Error()) + return + } + + Respond(c, http.StatusOK, settings) +} + +func (e *Env) GetUserSetting(c *gin.Context) { + user := c.Param("user") + name := c.Param("setting") + if user == "" || name == "" { + RespondError(c, http.StatusNotFound, "Username or setting not given") + return + } + + setting, err := e.backend.GetUserSettings(user, name) + if err != nil { + RespondError(c, 400, "Unable to get settings: %s", err.Error()) + return + } + + Respond(c, http.StatusOK, setting) +} + +func (e *Env) SetUserSettings(c *gin.Context) { + user := c.Param("user") + if user == "" { + RespondError(c, http.StatusNotFound, "No username given") + return + } + + settings := make(map[string]backend.Setting) + if err := c.ShouldBind(&settings); err != nil { + RespondError(c, 400, "Unable to read settings: %s", err.Error()) + return + } + + tx, err := e.backend.DB.Begin() + if err != nil { + RespondError(c, 400, "Unable to begin transaction: %s", err.Error()) + return + } + if _, err := e.backend.TxDeleteUserSettings(tx, user); err != nil { + tx.Rollback() + RespondError(c, 400, "Unable to truncate settings: %s", err.Error()) + return + } + if err := e.backend.TxUpdateUserSettings(tx, user, settings); err != nil { + RespondError(c, 400, "Unable to set settings: %s", err.Error()) + return + } + if err = tx.Commit(); err != nil { + RespondError(c, 400, "Unable to commit transaction: %s", err.Error()) + return + } + + Respond(c, http.StatusOK, settings) +} + +func (e *Env) SetUserSetting(c *gin.Context) { + user := c.Param("user") + name := c.Param("setting") + if user == "" || name == "" { + RespondError(c, http.StatusNotFound, "Username or setting name not given") + return + } + + var value interface{} + if err := c.ShouldBind(&value); err != nil { + RespondError(c, 400, "Unable to get value: %s", err.Error()) + return + } + + setting := make(map[string]backend.Setting) + setting[name] = value + if err := e.backend.UpdateUserSettings(user, setting); + err != nil { + RespondError(c, 400, "Unable to update %q: %s", setting, err.Error()) + } + + Respond(c, 200, setting) +} diff --git a/internal/backend/env.go b/internal/backend/env.go @@ -6,7 +6,7 @@ import ( ) type Env struct { - db *sql.DB + DB *sql.DB stmts map[string]*sql.Stmt } @@ -17,7 +17,7 @@ func NewEnv(db *sql.DB) (*Env, error) { return nil, errors.New("No database") } return &Env{ - db: db, + DB: db, stmts: make(map[string]*sql.Stmt), }, nil } @@ -28,7 +28,7 @@ func (e *Env) CacheStmt(name, sql string) (*sql.Stmt, error) { return stmt, nil } - stmt, err := e.db.Prepare(sql) + stmt, err := e.DB.Prepare(sql) if err != nil { return nil, err } @@ -40,5 +40,5 @@ func (e *Env) Free() { for _, s := range e.stmts { s.Close() } - e.db.Close() + e.DB.Close() } diff --git a/internal/backend/user.go b/internal/backend/user.go @@ -2,7 +2,9 @@ package backend import ( "errors" + "database/sql" "golang.org/x/crypto/bcrypt" + "github.com/lib/pq" ) // Database representation of user @@ -11,6 +13,16 @@ type User struct { hash string } +type Setting interface{} + +type rawSetting struct { + username *string + name *string + strval *string + numval *int + boolval *bool +} + func (e *Env) CreateUser(username string, password string) error { if username == "" { return errors.New("Empty username") @@ -22,11 +34,7 @@ func (e *Env) CreateUser(username string, password string) error { } user := User{ Name: username, hash: string(hash) } - err = e.insertUser(user) - if err != nil { - return err - } - return nil + return e.insertUser(user) } func (e *Env) DeleteUser(username string) error { @@ -72,6 +80,134 @@ func (e *Env) LoginUser(username string, password string) (User, error) { return user, nil; } +func (e *Env) UpdateUserSettings(username string, settings map[string]Setting) error { + tx, err := e.DB.Begin() + if err != nil { + return err + } + err = e.TxUpdateUserSettings(tx, username, settings) + if err != nil { + tx.Rollback() + return err + } + return tx.Commit() +} + +func (e *Env) TxUpdateUserSettings(tx *sql.Tx, username string, settings map[string]Setting) error { + stmt, err := e.CacheStmt("set_user_settings", `INSERT INTO user_settings VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (username, name) DO UPDATE + SET (strval, numval, boolval) = (EXCLUDED.strval, EXCLUDED.numval, EXCLUDED.boolval)`) + if err != nil { + return err + } + stmt = tx.Stmt(stmt) + + for name, setting := range settings { + r := toRawSetting(username, name, setting) + _, err = stmt.Exec(r.username, r.name, r.strval, r.numval, r.boolval) + if err != nil { + return err + } + } + + return nil +} + +func (e *Env) GetUserSettings(username string, names ...string) (map[string]Setting, error) { + stmt, err := e.CacheStmt("get_user_settings", `SELECT * FROM user_settings WHERE + user_settings.username = $1 AND + (ARRAY_LENGTH($2::varchar[], 1) IS NULL OR user_settings.name = ANY ($2))`) + if err != nil { + return nil, err + } + + rows, err := stmt.Query(username, pq.Array(names)) + if err != nil { + return nil, err + } + + return collectSettings(rows) +} + +func (e *Env) DeleteUserSettings(username string, names ...string) (map[string]Setting, error) { + var deleted map[string]Setting + tx, err := e.DB.Begin() + if err != nil { + return deleted, err + } + deleted, err = e.TxDeleteUserSettings(tx, username, names...) + if err != nil { + tx.Rollback() + return deleted, err + } + return deleted, tx.Commit() +} + +func (e *Env) TxDeleteUserSettings(tx *sql.Tx, username string, names ...string) (map[string]Setting, error) { + var deleted map[string]Setting + stmt, err := e.CacheStmt("del_user_settings", `DELETE FROM user_settings WHERE user_settings.username = $1 AND + (ARRAY_LENGTH($2::string[], 1) IS NULL OR user_settings.name = ANY ($2)) RETURNING`) + if err != nil { + return deleted, err + } + + rows, err := tx.Stmt(stmt).Query(username, names) + if err != nil { + return deleted, err + } + return collectSettings(rows) +} + +func toRawSetting(username string, name string, setting Setting) *rawSetting { + var b *bool + var i *int + var s *string + + switch v := setting.(type) { + case string: + s = &v + case int: + i = &v + case bool: + b = &v + default: + panic("Setting.Value: Unexpected type") + } + + return &rawSetting{ + username: &username, + name: &name, + strval: s, + numval: i, + boolval: b, + } +} + +func collectSettings(rows *sql.Rows) (map[string]Setting, error) { + settings := make(map[string]Setting) + for rows.Next() { + var setting Setting + var raw rawSetting + err := rows.Scan(&raw.username, &raw.name, &raw.strval, &raw.numval, &raw.boolval) + if err != nil { + return settings, err + } + + if raw.strval != nil { + setting = *raw.strval + } else if raw.numval != nil { + setting = *raw.numval + } else if raw.boolval != nil { + setting = *raw.boolval + } else { + return settings, errors.New("Database constraint error: no setting set") + } + settings[*raw.name] = setting + } + + return settings, nil +} + func (e *Env) insertUser(user User) error { stmt, err := e.CacheStmt("insert_user", "INSERT INTO users VALUES ($1, $2)") if err != nil {