commit - 861fc513c8968adc6aac3ab85377d3e6b9054705
commit + 7583dd64601bcc2973ff355bf18e4c09f471245e
blob - 7587d15c5f7a44e4d14b5ded3d1acaacd9d152f6
blob + df1f9d043def3d6b98d774a67bec68a14dbc6267
--- client.go
+++ client.go
package mailmux
import (
+ "bytes"
+ "encoding/json"
+ "errors"
"fmt"
"net/http"
"net/url"
const apiurl = "https://mailmux.net/v1/aliases"
+const jsonContentType = "application/json"
+
type Client struct {
*http.Client
+ addr string
user string
token string
}
-func NewClient(user, token string) *Client {
- return &Client{http.DefaultClient, user, token}
+func Dial(uri, user, token string) *Client {
+ return &Client{http.DefaultClient, uri, user, token}
}
+func (c *Client) Register(username, password string) error {
+ mcall := &Mcall{
+ Type: Tregister,
+ Username: username,
+ Password: password,
+ }
+
+ body := &bytes.Buffer{}
+ 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)
+ if err != nil {
+ return fmt.Errorf("register %s: %v", username, err)
+ }
+ if resp.StatusCode == http.StatusOK {
+ return nil
+ }
+
+ defer resp.Body.Close()
+ rerror, err := ParseMcall(resp.Body)
+ if err != nil {
+ return fmt.Errorf("register %s: parse response: %v", username, err)
+ }
+ return fmt.Errorf("register %s: %s", username, rerror.Error)
+}
+
func (c *Client) NewAlias() (Alias, error) {
v := url.Values{}
v.Add("username", c.user)
v.Add("token", c.token)
- resp, err := http.PostForm(apiurl, v)
+ resp, err := http.PostForm(c.addr + "/aliases", v)
if err != nil {
return Alias{}, err
}
defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return Alias{}, errors.New(resp.Status)
+ }
+
return Alias{}, nil
}
blob - cf7c3b7002ca03c91b23889a340c6b387bd92778
blob + 95eb5dc5f0570ad7be1eab427a0896ff34a8f85c
--- cmd/mailmux/mailmux.go
+++ cmd/mailmux/mailmux.go
import (
"encoding/json"
- "errors"
"fmt"
"log"
+ "math/rand"
"net/http"
"os"
"path"
+ "time"
mailmux "mailmux.net"
"mailmux.net/aliases"
users mailmux.UserStore
}
-func (srv *server) registerUser(name string, pw mailmux.Password) error {
- _, err := srv.users.Lookup(name)
- if err == nil {
- return mailmux.ErrUserExist
- }
- if errors.Is(err, mailmux.ErrUnknownUser) {
- return srv.users.Change(name, pw)
- }
- return err
-}
-
-func (srv *server) registerHandler(w http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodPost {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
- if err := req.ParseForm(); err != nil {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- log.Println(err)
- return
- }
-
- username := req.PostForm.Get("username")
- if username == "" {
- http.Error(w, "empty username", http.StatusBadRequest)
- return
- }
-
- pw := req.PostForm.Get("password")
- if pw == "" {
- http.Error(w, "empty password", http.StatusBadRequest)
- return
- }
- // TODO hash password
- password := mailmux.Password(pw)
-
- err := srv.registerUser(username, password)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- log.Println(err)
- }
-}
-
func (srv *server) aliasHandler(w http.ResponseWriter, req *http.Request) {
if err := req.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
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) {
+ mcall := &mailmux.Mcall{Type: mailmux.Rerror, Error: errormsg}
+ if err := json.NewEncoder(w).Encode(mcall); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(status)
+ return
+}
+
func main() {
cdir, err := os.UserCacheDir()
if err != nil {
os.Exit(1)
}
+ srv := &server{}
db, err := mailmux.OpenUserDB(path.Join(cdir, "/mailmux/db"), ticketDir)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
- srv := &server{users: db}
+ srv.users = db
srv.aliaspath = "/tmp/aliases"
srv.aliases, err = aliases.Load(srv.aliaspath)
blob - /dev/null
blob + 52f9c160dcf7b2ab08ae17f9b0923353ce2d9b0f (mode 644)
--- /dev/null
+++ cmd/mailmux/mailmux_test.go
+package main
+
+import (
+ "net"
+ "net/http"
+ "os"
+ "path"
+ "testing"
+
+ mailmux "mailmux.net"
+)
+
+func newTestServerClient(t *testing.T) *mailmux.Client {
+ dir, err := os.MkdirTemp("", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ db, err := mailmux.CreateUserDB(path.Join(dir, "db"), dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := &server{
+ users: db,
+ aliaspath: path.Join(dir, "aliases"),
+ seedfile: "/usr/share/dict/words",
+ }
+
+ ln, err := net.Listen("tcp", "[::1]:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ http.HandleFunc("/register", srv.registerHandler)
+ http.HandleFunc("/aliases", srv.aliasHandler)
+ go func() {
+ t.Fatal(http.Serve(ln, nil))
+ }()
+
+ return mailmux.Dial("http://" + ln.Addr().String(), "test", "test")
+}
+
+func TestBadRegistration(t *testing.T) {
+ client := newTestServerClient(t)
+ registrations := map[string]string{
+ "djfkjskdjf": "dfjkdkfjsd",
+ "": "asdfgjkl",
+ "fjdklskjdsf": "",
+ "@@@@": "dfjksjkdf",
+ "foo@example.com": "",
+ }
+ for username, password := range registrations {
+ err := client.Register(username, password)
+ if err == nil {
+ t.Errorf("nil error on bad registration username %q password %q", username, password)
+ }
+ }
+}
blob - /dev/null
blob + 46f5ee368b9a28a1cf6f475ad9dfa4a501d8d6e9 (mode 644)
--- /dev/null
+++ cmd/mailmux/register.go
+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 err == nil {
+ return mailmux.ErrUserExist
+ }
+ if errors.Is(err, mailmux.ErrUnknownUser) {
+ return srv.users.Change(name, pw)
+ }
+ 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 mcall mailmux.Mcall
+ if err := json.NewDecoder(req.Body).Decode(&mcall); err != nil {
+ rerror(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if mcall.Type != mailmux.Tregister {
+ s := fmt.Sprintf("mcall type %d is not Tregister", mcall.Type)
+ rerror(w, s, http.StatusBadRequest)
+ return
+ }
+ if mcall.Username == "" {
+ rerror(w, "empty username", http.StatusBadRequest)
+ return
+ }
+ if mcall.Password == "" {
+ rerror(w, "empty password", http.StatusBadRequest)
+ return
+ }
+
+ err := srv.registerUser(mcall.Username, mailmux.Password(mcall.Password))
+ if err != nil {
+ rerror(w, err.Error(), http.StatusInternalServerError)
+ }
+}
blob - 1cd07436a3759ef0d35ff6eb5dbb6b2afbfe336a
blob + 6b7bc2b7ecae600709724f269936478d9658cf2b
--- go.mod
+++ go.mod
go 1.17
-require github.com/mattn/go-sqlite3 v1.14.12 // indirect
+require (
+ github.com/mattn/go-sqlite3 v1.14.12 // indirect
+ golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect
+)
blob - a7151d6366cd2432431a58124bb54ea304dd7bf2
blob + 0dd4d68af6fa5591ec9e1e2767199c8a91538db2
--- go.sum
+++ go.sum
github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
+golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
blob - /dev/null
blob + 878bf43ea9dab8f037a8240bb85d7449f13ffd31 (mode 644)
--- /dev/null
+++ mcall.go
+package mailmux
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "time"
+)
+
+const (
+ Tregister = 1 + iota
+ Rregister
+ Rerror
+ Tnew
+ Rnew
+ Tlist
+ Rlist
+ Tremove
+)
+
+// An Mcall is a message passed between mailmux client and servers.
+// Operations requested by clients are T-messages (such as Tregister).
+// Servers respond with the corresponding R-message (such as Rregister) or Rerror to inform the client,
+// with a diagnostic message, that the request was not completed successfully.
+// This design is loosely based on the Plan 9 network file protocol 9P.
+type Mcall struct {
+ Type uint
+ Username string // Tregister, Rregister
+ Password string // Tregister
+ Error string // Rerror
+ Aliases []Alias // Rnew, Rlist, Tremove
+ Expiry time.Time // Tnew, Rnew
+}
+
+// ParseMcall parses and validates one Mcall from r.
+func ParseMcall(r io.Reader) (*Mcall, error) {
+ var mc Mcall
+ if err := json.NewDecoder(r).Decode(&mc); err != nil {
+ return nil, err
+ }
+ if mc.Username == "" {
+ return nil, errors.New("empty username")
+ }
+ if mc.Password == "" {
+ return nil, errors.New("empty password")
+ }
+ if mc.Type != Rerror && mc.Error != "" {
+ return nil, errors.New("non-empty error field")
+ }
+ if mc.Type == Rerror && mc.Error == "" {
+ return nil, errors.New("empty error message")
+ }
+ return &mc, nil
+}
blob - 86fb14489e0d0003570103a71faaf8434175ff0c
blob + 8b4fa6214d6f6971d2f7bf401433e257f6da7426
--- user.go
+++ user.go
"errors"
)
+// User represents a mailmux user.
type User struct {
+ // The name should be a valid email address.
name string
password Password
}
blob - 80ecd2b252ac071b45975e0e83cff33f46e62d36
blob + 4e32bae9d0de09c2bf5279927a10eaab881be5b7
--- userdb.go
+++ userdb.go
"database/sql"
"errors"
"fmt"
+ "net/mail"
"os"
"path"
_ "github.com/mattn/go-sqlite3"
+ "golang.org/x/crypto/bcrypt"
)
// UserDB is an implementation of UserStore backed by a SQLite3 database.
TicketDir string
}
+// CreateUserDB creates the named user database file and ticket directory.
+// If the database file already exists, it is truncated. If successfullly created,
+// the database's tables are initialised so the database is ready to use.
+func CreateUserDB(name, ticketDir string) (*UserDB, error) {
+ _, err := os.Create(name)
+ if err != nil {
+ return nil, err
+ }
+ if err := os.MkdirAll(ticketDir, 0666); err != nil {
+ return nil, fmt.Errorf("create user db: %w", err)
+ }
+ db, err := OpenUserDB(name, ticketDir)
+ if err != nil {
+ return nil, fmt.Errorf("create user db: %w", err)
+ }
+ return db, db.initialise()
+}
+
// OpenUserDB opens the named user database file and ticket directory.
func OpenUserDB(name, dir string) (*UserDB, error) {
db, err := sql.Open("sqlite3", name)
return &UserDB{db, dir}, db.Ping()
}
+func (db *UserDB) initialise() error {
+ stmt := `CREATE TABLE IF NOT EXISTS users (
+ username TEXT PRIMARY KEY,
+ password BLOB NOT NULL
+);`
+ _, err := db.Exec(stmt)
+ return err
+}
+
func (db *UserDB) Lookup(name string) (User, error) {
var u User
row := db.QueryRow("SELECT username, password FROM users WHERE username = ?", name)
}
func (db *UserDB) add(username string, pw Password) error {
- _, err := db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", username, pw)
+ addr, err := mail.ParseAddress(username)
if err != nil {
+ return fmt.Errorf("add %s: invalid username: %w", username, err)
+ }
+ if addr.Name != "" {
+ return fmt.Errorf("add %s: proper name present", username)
+ }
+
+ hashed, err := bcrypt.GenerateFromPassword(pw, bcrypt.DefaultCost)
+ if err != nil {
return fmt.Errorf("add %s: %w", username, err)
}
+ _, err = db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", username, hashed)
+ if err != nil {
+ return fmt.Errorf("add %s: %w", username, err)
+ }
if err := createTicket(db.TicketDir, username, pw); err != nil {
return fmt.Errorf("add %s: create ticket: %w", username, err)
}
return fmt.Errorf("change %s: %w", name, err)
}
- _, err = db.Exec("UPDATE users SET password = ? WHERE username = ?", new, name)
+ hashed, err := bcrypt.GenerateFromPassword(new, bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("change %s: %w", name, err)
}
+ _, err = db.Exec("UPDATE users SET password = ? WHERE username = ?", hashed, name)
+ if err != nil {
+ return fmt.Errorf("change %s: %w", name, err)
+ }
return nil
}
return nil
}
+func (db *UserDB) Authenticate(name string, pw Password) error {
+ u, err := db.Lookup(name)
+ if err != nil {
+ return fmt.Errorf("authenticate %s: %w", name, err)
+ }
+ if err := bcrypt.CompareHashAndPassword(u.password, pw); err != nil {
+ return fmt.Errorf("authenticate %s: %w", name, err)
+ }
+ return nil
+}
+
// TODO tickets aren't implemented yet
func createTicket(dir, username string, pw Password) error {
f, err := os.Create(path.Join(dir, username))