commit 925168e0252fd037ac6e89d98f75d613efdadef2 from: Oliver Lowe date: Thu Apr 14 08:20:24 2022 UTC Move server, non-cmd stuff out of cmd commit - 5e622b9bb50c661a19f5b6b32666e6f880776fca commit + 925168e0252fd037ac6e89d98f75d613efdadef2 blob - /dev/null blob + 3f859921e3e13b963781a960269da12b6986c579 (mode 644) --- /dev/null +++ alias.go @@ -0,0 +1,87 @@ +package mailmux + +import ( + "database/sql" + "fmt" + "time" +) + +type Alias struct { + Recipient string + Destination string + Expiry time.Time + Note string +} + +type AliasStore interface { + Create(dest string) (Alias, error) + Aliases(dest string) ([]Alias, error) + Delete(rcpt string) error +} + +type AliasDB struct { + *sql.DB + dictpath string +} + +func OpenAliasDB(name, dictpath string) (*AliasDB, error) { + db, err := sql.Open("sqlite3", name) + if err != nil { + return nil, err + } + stmt := ` +CREATE TABLE IF NOT EXISTS aliases ( + recipient TEXT PRIMARY KEY, + destination TEXT NOT NULL, + expiry INTEGER, + note TEXT +);` + _, err = db.Exec(stmt) + return &AliasDB{db, dictpath}, err +} + +func (db *AliasDB) Create(dest string) (Alias, error) { + rcpt, err := RandomUsername(db.dictpath) + if err != nil { + return Alias{}, fmt.Errorf("create alias: %w", err) + } + _, err = db.Exec("INSERT INTO aliases (recipient, destination) VALUES (?, ?)", rcpt, dest) + if err != nil { + return Alias{}, fmt.Errorf("create alias: %w", err) + } + return Alias{Recipient: rcpt, Destination: dest}, nil +} + +func (db *AliasDB) Aliases(dest string) ([]Alias, error) { + rows, err := db.Query("SELECT recipient, destination, expiry, note FROM aliases WHERE destination = ?", dest) + if err != nil { + return nil, fmt.Errorf("aliases for %s: %w", dest, err) + } + defer rows.Close() + var aliases []Alias + for rows.Next() { + var a Alias + var t sql.NullTime + var note sql.NullString + err := rows.Scan(&a.Recipient, &a.Destination, &t, ¬e) + if err != nil { + return aliases, err + } + if t.Valid { + a.Expiry = t.Time + } + if note.Valid { + a.Note = note.String + } + aliases = append(aliases, a) + } + return aliases, rows.Err() +} + +func (db *AliasDB) Delete(rcpt string) error { + _, err := db.Exec("DELETE FROM aliases WHERE recipient = ?", rcpt) + if err != nil { + return fmt.Errorf("delete %s: %w", rcpt, err) + } + return nil +} blob - 81ef5523f4eb5c7954232f88156d459c9429bc3a blob + 1e2d1a189ba11c0a2b3ceb7aaa37685ba5ee67e0 --- client.go +++ client.go @@ -5,8 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - - "mailmux.net/aliases" ) const apiurl = "https://mailmux.net/v1/aliases" @@ -15,13 +13,19 @@ const jsonContentType = "application/json" type Client struct { *http.Client - addr string + url string user string token string } -func Dial(uri, user, token string) *Client { - return &Client{http.DefaultClient, uri, user, token} +func Dial(addr, user, token string, tls bool) *Client { + var url string + if tls { + url = "https://" + addr + } else { + url = "http://" + addr + } + return &Client{http.DefaultClient, url, user, token} } func (c *Client) Register(username, password string) error { @@ -35,7 +39,7 @@ func (c *Client) Register(username, password string) e if err := json.NewEncoder(body).Encode(mcall); err != nil { return fmt.Errorf("register %s: %v", username, err) } - resp, err := c.Post(c.addr+"/register", jsonContentType, body) + resp, err := c.Post(c.url+"/register", jsonContentType, body) if err != nil { return fmt.Errorf("register %s: %v", username, err) } @@ -51,7 +55,7 @@ func (c *Client) Register(username, password string) e return fmt.Errorf("register %s: %s", username, rerror.Error) } -func (c *Client) NewAlias() (aliases.Aliases, error) { +func (c *Client) NewAlias() ([]Alias, error) { tmsg := &Mcall{ Type: Tnew, Username: c.user, @@ -62,9 +66,9 @@ func (c *Client) NewAlias() (aliases.Aliases, error) { return nil, fmt.Errorf("new alias: %w", err) } - resp, err := http.Post(c.addr+"/aliases", jsonContentType, buf) + resp, err := http.Post(c.url+"/alias", jsonContentType, buf) if err != nil { - return nil, fmt.Errorf("new alias: %w", err) + return nil, fmt.Errorf("new alias: POST tmsg: %w", err) } defer resp.Body.Close() rmsg, err := ParseMcall(resp.Body) @@ -77,7 +81,7 @@ func (c *Client) NewAlias() (aliases.Aliases, error) { return rmsg.Aliases, nil } -func (c *Client) Aliases() (aliases.Aliases, error) { +func (c *Client) Aliases() ([]Alias, error) { tmsg := &Mcall{ Type: Tlist, Username: c.user, @@ -87,7 +91,7 @@ func (c *Client) Aliases() (aliases.Aliases, error) { if err := json.NewEncoder(buf).Encode(tmsg); err != nil { return nil, fmt.Errorf("list aliases: %w", err) } - resp, err := http.Post(c.addr+"/aliases", jsonContentType, buf) + resp, err := http.Post(c.url+"/alias", jsonContentType, buf) if err != nil { return nil, fmt.Errorf("list aliases: %w", err) } blob - 5373d46e3aabb175b56ac070c0b75feb61afa2ce blob + 22fa8c2a63d6e2df882e06396a8ae9ba2b367294 --- cmd/mailmux/mailmux.go +++ cmd/mailmux/mailmux.go @@ -1,10 +1,7 @@ package main import ( - "encoding/json" - "errors" "fmt" - "io/fs" "log" "math/rand" "net/http" @@ -13,120 +10,12 @@ import ( "time" mailmux "mailmux.net" - "mailmux.net/aliases" ) -type server struct { - seedfile string - aliaspath string - aliases aliases.Aliases - users mailmux.UserStore +func init() { + rand.Seed(time.Now().UnixNano()) } -func (srv *server) aliasHandler(w http.ResponseWriter, req *http.Request) { - tmsg, err := mailmux.ParseMcall(req.Body) - if err != nil { - rerror(w, err.Error(), http.StatusBadRequest) - return - } - - err = srv.users.Authenticate(tmsg.Username, mailmux.Password(tmsg.Password)) - if err != nil { - rerror(w, "unauthorised", http.StatusUnauthorized) - log.Println(err) - return - } - - switch tmsg.Type { - case mailmux.Tnew: - srv.newAlias(w, tmsg) - return - case mailmux.Tlist: - srv.listAliasHandler(w, tmsg) - return - } - rerror(w, "not implemented yet", http.StatusNotImplemented) -} - -func (srv *server) listAliasHandler(w http.ResponseWriter, tmsg *mailmux.Mcall) { - filtered := make(aliases.Aliases) - for rcpt, dest := range srv.aliases { - if dest == tmsg.Username { - filtered[rcpt] = dest - } - } - rmsg := &mailmux.Mcall{ - Type: mailmux.Rlist, - Username: tmsg.Username, - Aliases: filtered, - } - if err := json.NewEncoder(w).Encode(rmsg); err != nil { - rerror(w, err.Error(), http.StatusInternalServerError) - return - } -} - -func (srv *server) newAlias(w http.ResponseWriter, tmsg *mailmux.Mcall) { - if tmsg.Type != mailmux.Tnew { - rerror(w, "message type is not Tnew", http.StatusBadRequest) - return - } - - rcpt, err := mailmux.RandomUsername(srv.seedfile) - if err != nil { - s := fmt.Sprintf("random username: %v", err) - rerror(w, s, http.StatusInternalServerError) - return - } - srv.aliases[rcpt] = tmsg.Username - if err := aliases.Put(srv.aliases, srv.aliaspath); err != nil { - s := fmt.Sprintf("put: %v", err) - rerror(w, s, http.StatusInternalServerError) - return - } - - rnew := &mailmux.Mcall{ - Type: mailmux.Rnew, - Username: tmsg.Username, - Aliases: aliases.Aliases{rcpt: tmsg.Username}, - } - if err := json.NewEncoder(w).Encode(rnew); err != nil { - rerror(w, err.Error(), http.StatusInternalServerError) - return - } -} - -func (srv *server) deleteAlias(w http.ResponseWriter, req *http.Request) { - username := req.Form.Get("username") - recipient := req.Form.Get("recipient") - if recipient == "" { - http.Error(w, "empty recipient", http.StatusBadRequest) - return - } - - for rcpt, dest := range srv.aliases { - if rcpt == recipient && dest == username { - delete(srv.aliases, rcpt) - w.WriteHeader(http.StatusNoContent) - return - } - } - msg := fmt.Sprintf("no alias for recpient %s with destination %s", recipient, username) - http.Error(w, msg, http.StatusNotFound) -} - -// rerror replies to the HTTP request with a JSON-encoded Rerror message -// with its Error field set to errormsg. -// Just like http.Error, callers should ensure no further writes are done to w. -func rerror(w http.ResponseWriter, errormsg string, status int) { - w.WriteHeader(status) - rmsg := &mailmux.Mcall{Type: mailmux.Rerror, Error: errormsg} - if err := json.NewEncoder(w).Encode(rmsg); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - func main() { cdir, err := os.UserCacheDir() if err != nil { @@ -138,32 +27,15 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - - srv := &server{} - srv.users, err = mailmux.OpenUserDB(path.Join(cdir, "/mailmux/db"), ticketDir) - if errors.Is(err, fs.ErrNotExist) { - srv.users, err = mailmux.CreateUserDB(path.Join(cdir, "/mailmux/db"), ticketDir) - } + udb, err := mailmux.OpenUserDB(path.Join(cdir, "/mailmux/db"), ticketDir) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - - srv.aliaspath = path.Join(cdir, "/mailmux/aliases") - srv.aliases, err = aliases.Load(srv.aliaspath) - if errors.Is(err, fs.ErrNotExist) { - _, err = os.Create(path.Join(cdir, "/mailmux/aliases")) - } + astore, err := mailmux.OpenAliasDB(path.Join(cdir, "/mailmux/db"), "/usr/share/dict/words") if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - - rand.Seed(time.Now().UnixNano()) - srv.seedfile = "/usr/share/dict/words" - - http.HandleFunc("/register", srv.registerHandler) - http.HandleFunc("/aliases", srv.aliasHandler) - - log.Fatal(http.ListenAndServe("[::1]:6969", nil)) + log.Fatal(http.ListenAndServe("[::1]:6969", mailmux.NewWebServer(astore, udb))) } blob - 2050aa72e2ed4a6392495d6fed4365660326a41c blob + 252dbce017446a93961926b7f8636ad5df4d5a4e --- cmd/mailmux/mailmux_test.go +++ cmd/mailmux/mailmux_test.go @@ -8,46 +8,43 @@ import ( "testing" mailmux "mailmux.net" - "mailmux.net/aliases" ) -func newTestServerClient(t *testing.T) *mailmux.Client { +func newTestServer(t *testing.T) (net.Listener, *http.Server) { dir, err := os.MkdirTemp("", "") if err != nil { t.Fatal(err) } - db, err := mailmux.CreateUserDB(path.Join(dir, "db"), dir) + ticketDir := path.Join(dir, "/mailmux/ticket") + if err := os.MkdirAll(ticketDir, 0777); err != nil { + t.Fatal(err) + } + udb, err := mailmux.OpenUserDB(path.Join(dir, "/mailmux/db"), ticketDir) if err != nil { t.Fatal(err) } - srv := &server{ - users: db, - aliases: make(aliases.Aliases), - aliaspath: path.Join(dir, "aliases"), - seedfile: "/usr/share/dict/words", + astore, err := mailmux.OpenAliasDB(path.Join(dir, "/mailmux/db"), "/usr/share/dict/words") + if err != nil { + t.Fatal(err) } - ln, err := net.Listen("tcp", "[::1]:0") if err != nil { t.Fatal(err) } - mux := http.NewServeMux() - mux.HandleFunc("/register", srv.registerHandler) - mux.HandleFunc("/aliases", srv.aliasHandler) - go func() { - t.Fatal(http.Serve(ln, mux)) - }() - - client := mailmux.Dial("http://"+ln.Addr().String(), "test@example.com", "test") - if err := client.Register("test@example.com", "test"); err != nil { - t.Fatal(err) + return ln, &http.Server{ + Addr: ln.Addr().String(), + Handler: mailmux.NewWebServer(astore, udb), } - return client } func TestBadRegistration(t *testing.T) { - client := newTestServerClient(t) + ln, srv := newTestServer(t) + defer srv.Close() + go func() { + srv.Serve(ln) + }() + client := mailmux.Dial(srv.Addr, "", "", false) registrations := map[string]string{ "djfkjskdjf": "dfjkdkfjsd", "": "asdfgjkl", @@ -64,15 +61,24 @@ func TestBadRegistration(t *testing.T) { } func TestAliases(t *testing.T) { - client := newTestServerClient(t) + ln, srv := newTestServer(t) + go func() { + srv.Serve(ln) + }() + defer srv.Close() + client := mailmux.Dial(srv.Addr, "test@example.com", "secret", false) + if err := client.Register("test@example.com", "secret"); err != nil { + t.Fatal(err) + } for i := 0; i <= 100; i++ { _, err := client.NewAlias() if err != nil { t.Fatal(err) } } - _, err := client.Aliases() + a, err := client.Aliases() if err != nil { t.Fatal(err) } + t.Log(a) } blob - 9428a3263c8c7a06be0274eedf32b489c0ca49f3 (mode 644) blob + /dev/null --- cmd/mailmux/register.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - - mailmux "mailmux.net" -) - -func (srv *server) registerUser(name string, pw mailmux.Password) error { - _, err := srv.users.Lookup(name) - if errors.Is(err, mailmux.ErrUnknownUser) { - return srv.users.Change(name, pw) - } - if err == nil { - return mailmux.ErrUserExist - } - return err -} - -func (srv *server) registerHandler(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - rerror(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - var tmsg mailmux.Mcall - if err := json.NewDecoder(req.Body).Decode(&tmsg); err != nil { - rerror(w, err.Error(), http.StatusBadRequest) - return - } - - if tmsg.Type != mailmux.Tregister { - s := fmt.Sprintf("mcall type %d is not Tregister", tmsg.Type) - rerror(w, s, http.StatusBadRequest) - return - } - if tmsg.Username == "" { - rerror(w, "empty username", http.StatusBadRequest) - return - } - if tmsg.Password == "" { - rerror(w, "empty password", http.StatusBadRequest) - return - } - - err := srv.registerUser(tmsg.Username, mailmux.Password(tmsg.Password)) - if err != nil { - rerror(w, err.Error(), http.StatusInternalServerError) - return - } -} blob - d6a73244b2364976cbef16028536aa644d35fb67 blob + 8c144c4624fd750edac385562d6df627ece50404 --- mcall.go +++ mcall.go @@ -5,8 +5,6 @@ import ( "errors" "io" "time" - - "mailmux.net/aliases" ) const ( @@ -28,11 +26,11 @@ const ( // This design is loosely based on the Plan 9 network file protocol 9P. type Mcall struct { Type uint - Username string `json:",omitempty"` // Tregister, Rregister - Password string `json:",omitempty"` // Tregister - Error string `json:",omitempty"` // Rerror - Aliases aliases.Aliases `json:",omitempty"` // Rnew, Rlist, Tremove - Expiry time.Time `json:",omitempty"` // Tnew, Rnew + Username string `json:",omitempty"` // Tregister, Rregister + Password string `json:",omitempty"` // Tregister + Error string `json:",omitempty"` // Rerror + Aliases []Alias `json:",omitempty"` // Rnew, Rlist, Tremove + Expiry time.Time `json:",omitempty"` // Tnew, Rnew } // ParseMcall parses and validates a JSON-encoded Mcall from r. blob - bb0b51d2b75a7b8148e02d22ae69b358e98d31db blob + 0002ddd8efae18df9bafc950863b2331ca9e5d21 --- userdb.go +++ userdb.go @@ -4,7 +4,6 @@ import ( "database/sql" "errors" "fmt" - "io/fs" "net/mail" "os" "path" @@ -41,16 +40,17 @@ func CreateUserDB(name, ticketDir string) (*UserDB, er // OpenUserDB opens the named user database file and ticket directory. func OpenUserDB(name, dir string) (*UserDB, error) { - // sql.Open creates a blank file if it finds it doesn't exist. - // Return early if the file doesn't exist so we can handle it ourselves. - _, err := os.Stat(name) - if errors.Is(err, fs.ErrNotExist) { - return nil, err - } db, err := sql.Open("sqlite3", name) if err != nil { return nil, err } + stmt := `CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password BLOB NOT NULL +);` + if _, err := db.Exec(stmt); err != nil { + return nil, err + } return &UserDB{db, dir}, db.Ping() } blob - /dev/null blob + d0d25d70ddf134d338c0dd36d983b041926a1a3c (mode 644) --- /dev/null +++ server.go @@ -0,0 +1,125 @@ +package mailmux + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" +) + +type Server struct { + aliases AliasStore + users UserStore +} + +// rerror replies to the HTTP request with a JSON-encoded Rerror message +// with its Error field set to errormsg. +// Just like http.Error, callers should ensure no further writes are done to w. +func rerror(w http.ResponseWriter, errormsg string, status int) { + w.WriteHeader(status) + rmsg := &Mcall{Type: Rerror, Error: errormsg} + if err := json.NewEncoder(w).Encode(rmsg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func NewWebServer(aliases AliasStore, users UserStore) http.Handler { + mux := http.NewServeMux() + srv := Server{aliases, users} + mux.HandleFunc("/register", srv.registerHandler) + mux.HandleFunc("/alias", srv.aliasHandler) + return mux +} + +func (srv *Server) registerHandler(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + rerror(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + defer req.Body.Close() + tmsg, err := ParseMcall(req.Body) + if err != nil { + rerror(w, err.Error(), http.StatusBadRequest) + return + } + if tmsg.Type != Tregister { + s := fmt.Sprintf("mcall type %d is not Tregister", tmsg.Type) + rerror(w, s, http.StatusBadRequest) + return + } + + _, err = srv.users.Lookup(tmsg.Username) + if err == nil { + rerror(w, "user already exists", http.StatusBadRequest) + return + } else if !errors.Is(err, ErrUnknownUser) { + rerror(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := srv.users.Change(tmsg.Username, Password(tmsg.Password)); err != nil { + rerror(w, err.Error(), http.StatusInternalServerError) + return + } + rmsg := &Mcall{ + Type: Rregister, + Username: tmsg.Username, + } + b, err := json.Marshal(rmsg) + if err != nil { + rerror(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write(b) +} + +func (srv *Server) aliasHandler(w http.ResponseWriter, req *http.Request) { + tmsg, err := ParseMcall(req.Body) + if err != nil { + rerror(w, err.Error(), http.StatusBadRequest) + return + } + + err = srv.users.Authenticate(tmsg.Username, Password(tmsg.Password)) + if err != nil { + rerror(w, "unauthorised", http.StatusUnauthorized) + log.Println(err) + return + } + + var rmsg *Mcall + switch tmsg.Type { + case Tnew: + rmsg = srv.newAlias(tmsg) + case Tlist: + rmsg = srv.listAliasHandler(tmsg) + default: + rerror(w, "not implemented yet", http.StatusNotImplemented) + return + } + if rmsg.Type == Rerror { + w.WriteHeader(http.StatusInternalServerError) + } + if err := json.NewEncoder(w).Encode(rmsg); err != nil { + rerror(w, err.Error(), http.StatusInternalServerError) + } +} + +func (srv *Server) newAlias(tmsg *Mcall) *Mcall { + alias, err := srv.aliases.Create(tmsg.Username) + if err != nil { + return &Mcall{Type: Rerror, Error: err.Error()} + } + return &Mcall{Type: Rnew, Username: tmsg.Username, Aliases: []Alias{alias}} +} + +func (srv *Server) listAliasHandler(tmsg *Mcall) *Mcall { + a, err := srv.aliases.Aliases(tmsg.Username) + if err != nil { + return &Mcall{Type: Rerror, Error: err.Error()} + } + return &Mcall{Type: Rlist, Username: tmsg.Username, Aliases: a} +}