From 9d8bd4a2df3eb7c12b845374b1b2aba6b95bba95 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 31 May 2022 22:27:55 -0500 Subject: [PATCH] restructure stuff --- server/cmd/api/api.go | 45 ++ server/cmd/api/api_test.go | 22 + server/cmd/{ => config}/config.go | 31 +- server/cmd/db/db.go | 7 + server/cmd/example_config.yml | 4 + server/cmd/main.go | 712 +++++++++++++++--------------- 6 files changed, 454 insertions(+), 367 deletions(-) create mode 100644 server/cmd/api/api.go create mode 100644 server/cmd/api/api_test.go rename server/cmd/{ => config}/config.go (55%) create mode 100644 server/cmd/db/db.go create mode 100644 server/cmd/example_config.yml diff --git a/server/cmd/api/api.go b/server/cmd/api/api.go new file mode 100644 index 0000000..ed976bd --- /dev/null +++ b/server/cmd/api/api.go @@ -0,0 +1,45 @@ +package api + +import ( + "fmt" + + "git.tilde.town/tildetown/bbj2/server/cmd/config" + "git.tilde.town/tildetown/bbj2/server/cmd/db" +) + +type HTTPError struct { + Msg string + Code int +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("%d %s", e.Code, e.Msg) +} + +type BBJResponse struct { + Error bool `json:"error"` + Data interface{} `json:"data"` + Usermap map[string]db.User `json:"usermap"` +} + +type API struct { + User *db.User + Opts config.Options +} + +func (a *API) InstanceInfo() (*BBJResponse, error) { + type instanceInfo struct { + InstanceName string `json:"instance_name"` + AllowAnon bool `json:"allow_anon"` + Admins []string + } + return &BBJResponse{ + Data: instanceInfo{ + InstanceName: a.Opts.Config.InstanceName, + AllowAnon: a.Opts.Config.AllowAnon, + Admins: a.Opts.Config.Admins, + }, + }, nil +} + +type ApiHandler func() (*BBJResponse, error) diff --git a/server/cmd/api/api_test.go b/server/cmd/api/api_test.go new file mode 100644 index 0000000..ec348db --- /dev/null +++ b/server/cmd/api/api_test.go @@ -0,0 +1,22 @@ +package api + +import ( + "testing" + + "git.tilde.town/tildetown/bbj2/server/cmd/config" +) + +func TestInstanceInfo(t *testing.T) { + ts := []struct { + name string + opts config.Options + wantResp *BBJResponse + wantErr HTTPError + }{} + + for _, tt := range ts { + t.Run(tt.name, func(t *testing.T) { + // TODO + }) + } +} diff --git a/server/cmd/config.go b/server/cmd/config/config.go similarity index 55% rename from server/cmd/config.go rename to server/cmd/config/config.go index 8484039..b4e50c1 100644 --- a/server/cmd/config.go +++ b/server/cmd/config/config.go @@ -1,7 +1,9 @@ -package main +package config import ( + "database/sql" "fmt" + "io" "os" yaml "gopkg.in/yaml.v3" @@ -14,7 +16,32 @@ const ( defaultDBPath = "db.sqlite3" ) -func parseConfig(configPath string) (*Config, error) { +type IOStreams struct { + Err io.Writer + Out io.Writer +} + +type Config struct { + Admins []string + Port int + Host string + InstanceName string `yaml:"instance_name"` + AllowAnon bool `yaml:"allow_anon"` + Debug bool + DBPath string `yaml:"db_path"` +} + +type Options struct { + ConfigPath string + IO IOStreams + Log func(string) + Logf func(string, ...interface{}) + Config Config + DB *sql.DB + Reset bool +} + +func ParseConfig(configPath string) (*Config, error) { cfgBytes, err := os.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) diff --git a/server/cmd/db/db.go b/server/cmd/db/db.go new file mode 100644 index 0000000..d9d6fcb --- /dev/null +++ b/server/cmd/db/db.go @@ -0,0 +1,7 @@ +package db + +type User struct { + ID string + Username string + Hash string +} diff --git a/server/cmd/example_config.yml b/server/cmd/example_config.yml new file mode 100644 index 0000000..ccee703 --- /dev/null +++ b/server/cmd/example_config.yml @@ -0,0 +1,4 @@ +allow_anon: true +instance_name: "t i l d e . t o w n" +admins: ["vilmibm", "natalia", "archangelic"] +port: 8099 diff --git a/server/cmd/main.go b/server/cmd/main.go index 7580748..59d8090 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -7,13 +7,14 @@ import ( "errors" "flag" "fmt" - "io" "net/http" "os" "strings" "time" - "github.com/google/uuid" + "git.tilde.town/tildetown/bbj2/server/cmd/api" + "git.tilde.town/tildetown/bbj2/server/cmd/config" + "git.tilde.town/tildetown/bbj2/server/cmd/db" _ "github.com/mattn/go-sqlite3" ) @@ -22,40 +23,15 @@ import ( //go:embed schema.sql var schemaSQL string -type Config struct { - Admins []string - Port int - Host string - InstanceName string `yaml:"instance_name"` - AllowAnon bool `yaml:"allow_anon"` - Debug bool - DBPath string `yaml:"db_path"` -} - -type iostreams struct { - Err io.Writer - Out io.Writer -} - -type options struct { - ConfigPath string - IO iostreams - Log func(string) - Logf func(string, ...interface{}) - Config Config - DB *sql.DB - Reset bool -} - func main() { var configFlag = flag.String("config", "config.yml", "A path to a config file.") var resetFlag = flag.Bool("reset", false, "reset the database. WARNING this wipes everything.") flag.Parse() - io := iostreams{ + io := config.IOStreams{ Err: os.Stderr, Out: os.Stdout, } - opts := &options{ + opts := &config.Options{ ConfigPath: *configFlag, Reset: *resetFlag, IO: io, @@ -77,14 +53,14 @@ func main() { type Teardown func() -func setupDB(opts *options) (Teardown, error) { +func setupDB(opts *config.Options) (Teardown, error) { db, err := sql.Open("sqlite3", opts.Config.DBPath) opts.DB = db return func() { db.Close() }, err } -func _main(opts *options) error { - cfg, err := parseConfig(opts.ConfigPath) +func _main(opts *config.Options) error { + cfg, err := config.ParseConfig(opts.ConfigPath) if err != nil { fmt.Fprintf(os.Stderr, "could not read config file '%s'", opts.ConfigPath) os.Exit(1) @@ -114,7 +90,7 @@ func _main(opts *options) error { return nil } -func ensureSchema(opts options) error { +func ensureSchema(opts config.Options) error { db := opts.DB if opts.Reset { @@ -149,7 +125,7 @@ func ensureSchema(opts options) error { return nil } -func handler(opts options, f http.HandlerFunc) http.HandlerFunc { +func handler(opts config.Options, f http.HandlerFunc) http.HandlerFunc { // TODO make this more real return func(w http.ResponseWriter, req *http.Request) { opts.Log(req.URL.Path) @@ -162,46 +138,27 @@ func handler(opts options, f http.HandlerFunc) http.HandlerFunc { // encryption, it doesn't really help anything. I'd rather have plaintext + // transport encryption and then, on the server side, proper salted hashing. -type User struct { - ID string - Username string - Hash string -} - -type BBJResponse struct { - Error bool `json:"error"` - Data interface{} `json:"data"` - Usermap map[string]User `json:"usermap"` -} - -func writeResponse(w http.ResponseWriter, resp BBJResponse) { +func writeResponse(w http.ResponseWriter, resp api.BBJResponse) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // NB breaking: i'm not just returning 200 always but using http status codes -func writeErrorResponse(w http.ResponseWriter, code int, resp BBJResponse) { +func writeErrorResponse(w http.ResponseWriter, code int, resp api.BBJResponse) { w.WriteHeader(code) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } -func getUserFromReq(opts options, req *http.Request) (u User, err error) { +func getUserFromReq(opts config.Options, req *http.Request) (u *db.User, err error) { + u = &db.User{} u.Username = req.Header.Get("User") u.Hash = req.Header.Get("Auth") - if u.Username == "" { - err = errors.New("no User header set") + if u.Username == "" || u.Username == "anon" { return } - if u.Username == "anon" { - if !opts.Config.AllowAnon { - err = errors.New("anonymous access disabled") - return - } - } - db := opts.DB stmt, err := db.Prepare("select auth_hash, id from users where user_name = ?") if err != nil { @@ -228,7 +185,7 @@ func getUserFromReq(opts options, req *http.Request) (u User, err error) { return } -func checkAuth(opts options, username, hash string) error { +func checkAuth(opts config.Options, username, hash string) error { db := opts.DB stmt, err := db.Prepare("select auth_hash from users where user_name = ?") if err != nil { @@ -253,325 +210,350 @@ func checkAuth(opts options, username, hash string) error { return nil } -func setupAPI(opts options) { - serverErr := func(w http.ResponseWriter, err error) { - opts.Logf(err.Error()) - writeErrorResponse(w, 500, BBJResponse{ - Error: true, - Data: "server error", - }) - } - - badMethod := func(w http.ResponseWriter) { - writeErrorResponse(w, 400, BBJResponse{ - Error: true, - Data: "bad method", - }) - } - - invalidArgs := func(w http.ResponseWriter) { - writeErrorResponse(w, 400, BBJResponse{ - Error: true, - Data: "invalid args", - }) - } - - http.HandleFunc("/instance_info", handler(opts, func(w http.ResponseWriter, req *http.Request) { - type instanceInfo struct { - InstanceName string `json:"instance_name"` - AllowAnon bool `json:"allow_anon"` - Admins []string - } - writeResponse(w, BBJResponse{ - Data: instanceInfo{ - InstanceName: opts.Config.InstanceName, - AllowAnon: opts.Config.AllowAnon, - Admins: opts.Config.Admins, - }, - }) - })) - - http.HandleFunc("/user_register", handler(opts, func(w http.ResponseWriter, req *http.Request) { - if req.Method != "POST" { - badMethod(w) - return - } - - type AuthArgs struct { - Username string `json:"user_name"` - Hash string `json:"auth_hash"` - } - - var args AuthArgs - if err := json.NewDecoder(req.Body).Decode(&args); err != nil { - invalidArgs(w) - return - } - - if args.Hash == "" || args.Username == "" { - invalidArgs(w) - return - } - - opts.Logf("querying for %s", args.Username) - - if err := checkAuth(opts, args.Username, args.Hash); err == nil { - opts.Logf("found %s", args.Username) - // code 4 apparently - writeErrorResponse(w, 403, BBJResponse{ - Error: true, - Data: "user already exists", - }) - } else if err.Error() != "no such user" { - serverErr(w, err) - return - } - - db := opts.DB - stmt, err := db.Prepare(`INSERT INTO users VALUES (?, ?, ?, "", "", 0, 0, ?)`) - id, err := uuid.NewRandom() - if err != nil { - serverErr(w, err) - return - } - - _, err = stmt.Exec(id, args.Username, args.Hash, time.Now()) - if err != nil { - serverErr(w, err) - } - - writeResponse(w, BBJResponse{ - Data: true, // TODO probably something else - // TODO prob usermap - }) - })) - - http.HandleFunc("/check_auth", handler(opts, func(w http.ResponseWriter, req *http.Request) { - if req.Method != "POST" { - badMethod(w) - return - } - - type AuthArgs struct { - Username string `json:"target_user"` - AuthHash string `json:"target_hash"` - } - - var args AuthArgs - if err := json.NewDecoder(req.Body).Decode(&args); err != nil { - invalidArgs(w) - return - } - - opts.Logf("got %s %s", args.Username, args.AuthHash) - - db := opts.DB - - stmt, err := db.Prepare("select auth_hash from users where user_name = ?") - if err != nil { - serverErr(w, err) - return - } - defer stmt.Close() - - var authHash string - err = stmt.QueryRow(args.Username).Scan(&authHash) - if err != nil { - if strings.Contains(err.Error(), "no rows in result") { - opts.Logf("user not found") - writeErrorResponse(w, 404, BBJResponse{ - Error: true, - Data: "user not found", - }) - } else { - serverErr(w, err) - } - return - } - - // TODO unique constraint on user_name - - if authHash != args.AuthHash { - http.Error(w, "incorrect password", 403) - writeErrorResponse(w, 403, BBJResponse{ - Error: true, - Data: "incorrect password", - }) - return - } - - // TODO include usermap? - writeResponse(w, BBJResponse{ - Data: true, - }) - })) - - http.HandleFunc("/thread_index", handler(opts, func(w http.ResponseWriter, req *http.Request) { - db := opts.DB - rows, err := db.Query("SELECT * FROM threads JOIN messages ON threads.thread_id = messages.thread_id") - if err != nil { - serverErr(w, err) - return - } - defer rows.Close() - for rows.Next() { - var id string - err = rows.Scan(&id) - if err != nil { - serverErr(w, err) - return - } - opts.Log(id) - } - writeResponse(w, BBJResponse{Data: "TODO"}) - // TODO - })) - - http.HandleFunc("/thread_create", handler(opts, func(w http.ResponseWriter, req *http.Request) { - if req.Method != "POST" { - badMethod(w) - return - } - - // TODO make this getUserInfoFromReq or similar so we can use the user ID later +func setupAPI(opts config.Options) { + newAPI := func(opts config.Options, w http.ResponseWriter, req *http.Request) *api.API { user, err := getUserFromReq(opts, req) if err != nil { - writeErrorResponse(w, 403, BBJResponse{ + writeErrorResponse(w, 403, api.BBJResponse{ Error: true, Data: err.Error(), }) - return + return nil } - - type threadCreateArgs struct { - Title string - Body string - SendRaw bool `json:"send_raw"` + return &api.API{ + Opts: opts, + User: user, } + } - var args threadCreateArgs - if err := json.NewDecoder(req.Body).Decode(&args); err != nil { - invalidArgs(w) - return - } - - if args.Title == "" || args.Body == "" { - invalidArgs(w) - return - } - - db := opts.DB - tx, err := db.Begin() + invokeAPI := func(w http.ResponseWriter, apiFn api.ApiHandler) { + resp, err := apiFn() if err != nil { - serverErr(w, err) - return - } - - stmt, err := tx.Prepare("insert into threads VALUES ( ?, ?, ?, ?, ?, 0, 0, ? )") - if err != nil { - serverErr(w, err) - return - } - defer stmt.Close() - - threadID, err := uuid.NewRandom() - if err != nil { - serverErr(w, err) - return - } - now := time.Now() - if _, err = stmt.Exec( - threadID, - user.ID, - args.Title, - now, - now, - user.Username, - ); err != nil { - serverErr(w, err) - return - } - - stmt, err = tx.Prepare("insert into messages values ( ?, 1, ?, ?, 0, ?, ? )") - if err != nil { - serverErr(w, err) - return - } - defer stmt.Close() - - if _, err = stmt.Exec( - threadID, - user.ID, - now, - args.Body, - args.SendRaw, - ); err != nil { - serverErr(w, err) - return - } - - if err = tx.Commit(); err != nil { - serverErr(w, err) - return - } - - stmt, err = db.Prepare("select * from threads where thread_id = ? limit 1") - if err != nil { - serverErr(w, err) - return - } - defer stmt.Close() - - t := &Thread{} - - // TODO fill in rest of thread - if err = stmt.QueryRow(threadID).Scan( - t.ID, - t.Author, - t.Title, - t.LastMod, - t.Created, - t.ReplyCount, - t.Pinned, - t.LastAuthor, - ); err != nil { - serverErr(w, err) - return - } - - stmt, err = db.Prepare("select * from messages where thread_id = ?") - if err != nil { - serverErr(w, err) - return - } - defer stmt.Close() - rows, err := stmt.Query(threadID) - if err != nil { - serverErr(w, err) - return - } - - t.Messages = []Message{} - - for rows.Next() { - m := &Message{} - if err := rows.Scan( - m.ThreadID, - m.PostID, - m.Author, - m.Created, - m.Edited, - m.Body, - m.SendRaw, - ); err != nil { - serverErr(w, err) - return + he := &api.HTTPError{} + _ = errors.As(err, &he) + resp := api.BBJResponse{ + Error: true, + Data: he.Msg, } - t.Messages = append(t.Messages, *m) + w.WriteHeader(he.Code) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return } - writeResponse(w, BBJResponse{Data: t}) + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + } + + http.HandleFunc("/instance_info", handler(opts, func(w http.ResponseWriter, req *http.Request) { + api := newAPI(opts, w, req) + if api == nil { + return + } + invokeAPI(w, api.InstanceInfo) })) + + /* + http.HandleFunc("/instance_info", handler(opts, func(w http.ResponseWriter, req *http.Request) { + type instanceInfo struct { + InstanceName string `json:"instance_name"` + AllowAnon bool `json:"allow_anon"` + Admins []string + } + writeResponse(w, BBJResponse{ + Data: instanceInfo{ + InstanceName: opts.Config.InstanceName, + AllowAnon: opts.Config.AllowAnon, + Admins: opts.Config.Admins, + }, + }) + })) + + + http.HandleFunc("/user_register", handler(opts, func(w http.ResponseWriter, req *http.Request) { + if req.Method != "POST" { + badMethod(w) + return + } + + type AuthArgs struct { + Username string `json:"user_name"` + Hash string `json:"auth_hash"` + } + + var args AuthArgs + if err := json.NewDecoder(req.Body).Decode(&args); err != nil { + invalidArgs(w) + return + } + + if args.Hash == "" || args.Username == "" { + invalidArgs(w) + return + } + + opts.Logf("querying for %s", args.Username) + + if err := checkAuth(opts, args.Username, args.Hash); err == nil { + opts.Logf("found %s", args.Username) + // code 4 apparently + writeErrorResponse(w, 403, BBJResponse{ + Error: true, + Data: "user already exists", + }) + } else if err.Error() != "no such user" { + serverErr(w, err) + return + } + + db := opts.DB + stmt, err := db.Prepare(`INSERT INTO users VALUES (?, ?, ?, "", "", 0, 0, ?)`) + id, err := uuid.NewRandom() + if err != nil { + serverErr(w, err) + return + } + + _, err = stmt.Exec(id, args.Username, args.Hash, time.Now()) + if err != nil { + serverErr(w, err) + } + + writeResponse(w, BBJResponse{ + Data: true, // TODO probably something else + // TODO prob usermap + }) + })) + + http.HandleFunc("/check_auth", handler(opts, func(w http.ResponseWriter, req *http.Request) { + if req.Method != "POST" { + badMethod(w) + return + } + + type AuthArgs struct { + Username string `json:"target_user"` + AuthHash string `json:"target_hash"` + } + + var args AuthArgs + if err := json.NewDecoder(req.Body).Decode(&args); err != nil { + invalidArgs(w) + return + } + + opts.Logf("got %s %s", args.Username, args.AuthHash) + + db := opts.DB + + stmt, err := db.Prepare("select auth_hash from users where user_name = ?") + if err != nil { + serverErr(w, err) + return + } + defer stmt.Close() + + var authHash string + err = stmt.QueryRow(args.Username).Scan(&authHash) + if err != nil { + if strings.Contains(err.Error(), "no rows in result") { + opts.Logf("user not found") + writeErrorResponse(w, 404, BBJResponse{ + Error: true, + Data: "user not found", + }) + } else { + serverErr(w, err) + } + return + } + + // TODO unique constraint on user_name + + if authHash != args.AuthHash { + http.Error(w, "incorrect password", 403) + writeErrorResponse(w, 403, BBJResponse{ + Error: true, + Data: "incorrect password", + }) + return + } + + // TODO include usermap? + writeResponse(w, BBJResponse{ + Data: true, + }) + })) + + http.HandleFunc("/thread_index", handler(opts, func(w http.ResponseWriter, req *http.Request) { + db := opts.DB + rows, err := db.Query("SELECT * FROM threads JOIN messages ON threads.thread_id = messages.thread_id") + if err != nil { + serverErr(w, err) + return + } + defer rows.Close() + for rows.Next() { + var id string + err = rows.Scan(&id) + if err != nil { + serverErr(w, err) + return + } + opts.Log(id) + } + writeResponse(w, BBJResponse{Data: "TODO"}) + // TODO + })) + + http.HandleFunc("/thread_create", handler(opts, func(w http.ResponseWriter, req *http.Request) { + if req.Method != "POST" { + badMethod(w) + return + } + + // TODO make this getUserInfoFromReq or similar so we can use the user ID later + user, err := getUserFromReq(opts, req) + if err != nil { + writeErrorResponse(w, 403, BBJResponse{ + Error: true, + Data: err.Error(), + }) + return + } + + type threadCreateArgs struct { + Title string + Body string + SendRaw bool `json:"send_raw"` + } + + var args threadCreateArgs + if err := json.NewDecoder(req.Body).Decode(&args); err != nil { + invalidArgs(w) + return + } + + if args.Title == "" || args.Body == "" { + invalidArgs(w) + return + } + + db := opts.DB + tx, err := db.Begin() + if err != nil { + serverErr(w, err) + return + } + + stmt, err := tx.Prepare("insert into threads VALUES ( ?, ?, ?, ?, ?, 0, 0, ? )") + if err != nil { + serverErr(w, err) + return + } + defer stmt.Close() + + threadID, err := uuid.NewRandom() + if err != nil { + serverErr(w, err) + return + } + now := time.Now() + if _, err = stmt.Exec( + threadID, + user.ID, + args.Title, + now, + now, + user.Username, + ); err != nil { + serverErr(w, err) + return + } + + stmt, err = tx.Prepare("insert into messages values ( ?, 1, ?, ?, 0, ?, ? )") + if err != nil { + serverErr(w, err) + return + } + defer stmt.Close() + + if _, err = stmt.Exec( + threadID, + user.ID, + now, + args.Body, + args.SendRaw, + ); err != nil { + serverErr(w, err) + return + } + + if err = tx.Commit(); err != nil { + serverErr(w, err) + return + } + + stmt, err = db.Prepare("select * from threads where thread_id = ? limit 1") + if err != nil { + serverErr(w, err) + return + } + defer stmt.Close() + + t := &Thread{} + + // TODO fill in rest of thread + if err = stmt.QueryRow(threadID).Scan( + t.ID, + t.Author, + t.Title, + t.LastMod, + t.Created, + t.ReplyCount, + t.Pinned, + t.LastAuthor, + ); err != nil { + serverErr(w, err) + return + } + + stmt, err = db.Prepare("select * from messages where thread_id = ?") + if err != nil { + serverErr(w, err) + return + } + defer stmt.Close() + rows, err := stmt.Query(threadID) + if err != nil { + serverErr(w, err) + return + } + + t.Messages = []Message{} + + for rows.Next() { + m := &Message{} + if err := rows.Scan( + m.ThreadID, + m.PostID, + m.Author, + m.Created, + m.Edited, + m.Body, + m.SendRaw, + ); err != nil { + serverErr(w, err) + return + } + t.Messages = append(t.Messages, *m) + } + + writeResponse(w, BBJResponse{Data: t}) + + })) + */ } type Thread struct {