diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..550a295 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +JS_SOURCES = $(wildcard js/*.js) $(wildcard js/*/*.js) + +all: static/bundle.js static/react-widgets + +node_modules: + npm install + +static/bundle.js: $(JS_SOURCES) node_modules + browserify -t [ babelify --presets [ react ] ] js/main.js -o static/bundle.js + +static/react-widgets: node_modules/react-widgets/dist node_modules + rsync -a node_modules/react-widgets/dist/ static/react-widgets/ + +.PHONY = all diff --git a/db.go b/db.go new file mode 100644 index 0000000..178d4ee --- /dev/null +++ b/db.go @@ -0,0 +1,28 @@ +package main + +import ( + "database/sql" + _ "github.com/mattn/go-sqlite3" + "gopkg.in/gorp.v1" + "log" +) + +var DB *gorp.DbMap = initDB() + +func initDB() *gorp.DbMap { + db, err := sql.Open("sqlite3", "file:lunch.sqlite?cache=shared&mode=rwc") + if err != nil { + log.Fatal(err) + } + + dbmap := &gorp.DbMap{Db: db, Dialect: gorp.SqliteDialect{}} + dbmap.AddTableWithName(User{}, "users").SetKeys(true, "UserId") + dbmap.AddTableWithName(Session{}, "sessions").SetKeys(true, "SessionId") + + err = dbmap.CreateTablesIfNotExists() + if err != nil { + log.Fatal(err) + } + + return dbmap +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..206404b --- /dev/null +++ b/errors.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" +) + +type Error struct { + ErrorId int + ErrorString string +} + +var error_codes = map[int]string{ + 1: "Not Signed In", + 2: "Unauthorized Access", + 3: "Invalid Request", + 4: "User Exists", + // 5: "Connection Failed", //client-side error + 999: "Internal Error", +} + +func WriteError(w http.ResponseWriter, error_code int) { + msg, ok := error_codes[error_code] + if !ok { + log.Printf("Error: WriteError received error code of %d", error_code) + msg = error_codes[999] + } + e := Error{error_code, msg} + + enc := json.NewEncoder(w) + err := enc.Encode(e) + if err != nil { + log.Fatal(err) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d494504 --- /dev/null +++ b/main.go @@ -0,0 +1,84 @@ +package main + +//go:generate make + +import ( + "flag" + "github.com/gorilla/context" + "log" + "net" + "net/http" + "net/http/fcgi" + "os" + "path" + "strconv" +) + +var serveFcgi bool +var baseDir string +var tmpDir string +var port int +var smtpServer string +var smtpPort int +var smtpUsername string +var smtpPassword string +var reminderEmail string + +func init() { + flag.StringVar(&baseDir, "base", "./", "Base directory for server") + flag.StringVar(&tmpDir, "tmp", "/tmp", "Directory to create temporary files in") + flag.IntVar(&port, "port", 80, "Port to serve API/files on") + flag.StringVar(&smtpServer, "smtp.server", "smtp.example.com", "SMTP server to send reminder emails from.") + flag.IntVar(&smtpPort, "smtp.port", 587, "SMTP server port to connect to") + flag.StringVar(&smtpUsername, "smtp.username", "moneygo", "SMTP username") + flag.StringVar(&smtpPassword, "smtp.password", "password", "SMTP password") + flag.StringVar(&reminderEmail, "email", "moneygo@example.com", "Email address to send reminder emails as.") + flag.BoolVar(&serveFcgi, "fcgi", false, "Serve via fcgi rather than http.") + flag.Parse() + + static_path := path.Join(baseDir, "static") + + // Ensure base directory is valid + dir_err_str := "The base directory doesn't look like it contains the " + + "'static' directory. Check to make sure you're passing the right " + + "value to the -base argument." + static_dir, err := os.Stat(static_path) + if err != nil { + log.Print(err) + log.Fatal(dir_err_str) + } + if !static_dir.IsDir() { + log.Fatal(dir_err_str) + } + + // Setup the logging flags to be printed + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, path.Join(baseDir, "static/index.html")) +} + +func staticHandler(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, path.Join(baseDir, r.URL.Path)) +} + +func main() { + servemux := http.NewServeMux() + servemux.HandleFunc("/", rootHandler) + servemux.HandleFunc("/static/", staticHandler) + servemux.HandleFunc("/session/", SessionHandler) + servemux.HandleFunc("/user/", UserHandler) + + listener, err := net.Listen("tcp", ":"+strconv.Itoa(port)) + if err != nil { + log.Fatal(err) + } + + log.Printf("Serving on port %d out of directory: %s", port, baseDir) + if serveFcgi { + fcgi.Serve(listener, context.ClearHandler(servemux)) + } else { + http.Serve(listener, context.ClearHandler(servemux)) + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5f84e84 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "lunch", + "version": "0.0.1", + "description": "A lunch management and tracking web application", + "main": "js/main.js", + "dependencies": { + "babel-preset-react": "^6.16.0", + "babelify": "^7.3.0", + "big.js": "^3.1.3", + "browserify": "^13.1.0", + "cldr-data": "^29.0.2", + "globalize": "^1.1.1", + "keymirror": "^0.1.1", + "react": "^15.3.2", + "react-addons-update": "^15.3.2", + "react-bootstrap": "^0.30.5", + "react-dom": "^15.3.2", + "react-redux": "^4.4.5", + "react-widgets": "^3.4.4", + "redux": "^3.6.0", + "redux-thunk": "^2.1.0" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://git.noughth.com/aclindsa/lunch.git" + }, + "author": "Aaron Lindsay", + "bugs": { + "url": "https://git.noughth.com/aclindsa/lunch/issues" + }, + "homepage": "https://git.noughth.com/aclindsa/lunch" +} diff --git a/sessions.go b/sessions.go new file mode 100644 index 0000000..d3e23f8 --- /dev/null +++ b/sessions.go @@ -0,0 +1,124 @@ +package main + +import ( + "encoding/json" + "fmt" + "github.com/gorilla/securecookie" + "github.com/gorilla/sessions" + "log" + "net/http" +) + +var cookie_store = sessions.NewCookieStore(securecookie.GenerateRandomKey(64)) + +type Session struct { + SessionId int64 + SessionSecret string `json:"-"` + UserId int64 +} + +func (s *Session) Write(w http.ResponseWriter) error { + enc := json.NewEncoder(w) + return enc.Encode(s) +} + +func GetSession(r *http.Request) (*Session, error) { + var s Session + + session, _ := cookie_store.Get(r, "moneygo") + _, ok := session.Values["session-secret"] + if !ok { + return nil, fmt.Errorf("session-secret cookie not set") + } + s.SessionSecret = session.Values["session-secret"].(string) + + err := DB.SelectOne(&s, "SELECT * from sessions where SessionSecret=?", s.SessionSecret) + if err != nil { + return nil, err + } + return &s, nil +} + +func DeleteSessionIfExists(r *http.Request) { + session, err := GetSession(r) + if err == nil { + DB.Delete(session) + } +} + +func NewSession(w http.ResponseWriter, r *http.Request, userid int64) (*Session, error) { + s := Session{} + + session, _ := cookie_store.Get(r, "moneygo") + + session.Values["session-secret"] = string(securecookie.GenerateRandomKey(64)) + s.SessionSecret = session.Values["session-secret"].(string) + s.UserId = userid + + err := DB.Insert(&s) + if err != nil { + return nil, err + } + + err = session.Save(r, w) + if err != nil { + return nil, err + } else { + return &s, nil + } +} + +func SessionHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" || r.Method == "PUT" { + user_json := r.PostFormValue("user") + if user_json == "" { + WriteError(w, 3 /*Invalid Request*/) + return + } + + user := User{} + err := user.Read(user_json) + if err != nil { + WriteError(w, 3 /*Invalid Request*/) + return + } + + dbuser, err := GetUserByUsername(user.Username) + if err != nil { + WriteError(w, 2 /*Unauthorized Access*/) + return + } + + user.HashPassword() + if user.PasswordHash != dbuser.PasswordHash { + WriteError(w, 2 /*Unauthorized Access*/) + return + } + + DeleteSessionIfExists(r) + + session, err := NewSession(w, r, dbuser.UserId) + if err != nil { + WriteError(w, 999 /*Internal Error*/) + return + } + + err = session.Write(w) + if err != nil { + WriteError(w, 999 /*Internal Error*/) + log.Print(err) + return + } + } else if r.Method == "GET" { + s, err := GetSession(r) + if err != nil { + WriteError(w, 1 /*Not Signed In*/) + return + } + + s.Write(w) + } else if r.Method == "DELETE" { + DeleteSessionIfExists(r) + WriteSuccess(w) + } +} diff --git a/users.go b/users.go new file mode 100644 index 0000000..1799db2 --- /dev/null +++ b/users.go @@ -0,0 +1,216 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" +) + +type User struct { + UserId int64 + Name string + Username string + Password string `db:"-"` + PasswordHash string `json:"-"` + Email string +} + +const BogusPassword = "password" + +type UserExistsError struct{} + +func (ueu UserExistsError) Error() string { + return "User exists" +} + +func (u *User) Write(w http.ResponseWriter) error { + enc := json.NewEncoder(w) + return enc.Encode(u) +} + +func (u *User) Read(json_str string) error { + dec := json.NewDecoder(strings.NewReader(json_str)) + return dec.Decode(u) +} + +func (u *User) HashPassword() { + password_hasher := sha256.New() + io.WriteString(password_hasher, u.Password) + u.PasswordHash = fmt.Sprintf("%x", password_hasher.Sum(nil)) + u.Password = "" +} + +func GetUser(userid int64) (*User, error) { + var u User + + err := DB.SelectOne(&u, "SELECT * from users where UserId=?", userid) + if err != nil { + return nil, err + } + return &u, nil +} + +func GetUserByUsername(username string) (*User, error) { + var u User + + err := DB.SelectOne(&u, "SELECT * from users where Username=?", username) + if err != nil { + return nil, err + } + return &u, nil +} + +func InsertUser(u *User) error { + transaction, err := DB.Begin() + if err != nil { + return err + } + + existing, err := transaction.SelectInt("SELECT count(*) from users where Username=?", u.Username) + if err != nil { + transaction.Rollback() + return err + } + if existing > 0 { + transaction.Rollback() + return UserExistsError{} + } + + err = transaction.Insert(u) + if err != nil { + transaction.Rollback() + return err + } + + err = transaction.Commit() + if err != nil { + transaction.Rollback() + return err + } + + return nil +} + +func GetUserFromSession(r *http.Request) (*User, error) { + s, err := GetSession(r) + if err != nil { + return nil, err + } + return GetUser(s.UserId) +} + +func UserHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + user_json := r.PostFormValue("user") + if user_json == "" { + WriteError(w, 3 /*Invalid Request*/) + return + } + + var user User + err := user.Read(user_json) + if err != nil { + WriteError(w, 3 /*Invalid Request*/) + return + } + user.UserId = -1 + user.HashPassword() + + err = InsertUser(&user) + if err != nil { + if _, ok := err.(UserExistsError); ok { + WriteError(w, 4 /*User Exists*/) + } else { + WriteError(w, 999 /*Internal Error*/) + log.Print(err) + } + return + } + + w.WriteHeader(201 /*Created*/) + err = user.Write(w) + if err != nil { + WriteError(w, 999 /*Internal Error*/) + log.Print(err) + return + } + } else { + user, err := GetUserFromSession(r) + if err != nil { + WriteError(w, 1 /*Not Signed In*/) + return + } + + userid, err := GetURLID(r.URL.Path) + if err != nil { + WriteError(w, 3 /*Invalid Request*/) + return + } + + if userid != user.UserId { + WriteError(w, 2 /*Unauthorized Access*/) + return + } + + if r.Method == "GET" { + err = user.Write(w) + if err != nil { + WriteError(w, 999 /*Internal Error*/) + log.Print(err) + return + } + } else if r.Method == "PUT" { + user_json := r.PostFormValue("user") + if user_json == "" { + WriteError(w, 3 /*Invalid Request*/) + return + } + + // Save old PWHash in case the new password is bogus + old_pwhash := user.PasswordHash + + err = user.Read(user_json) + if err != nil || user.UserId != userid { + WriteError(w, 3 /*Invalid Request*/) + return + } + + // If the user didn't create a new password, keep their old one + if user.Password != BogusPassword { + user.HashPassword() + } else { + user.Password = "" + user.PasswordHash = old_pwhash + } + + count, err := DB.Update(user) + if count != 1 || err != nil { + WriteError(w, 999 /*Internal Error*/) + log.Print(err) + return + } + + err = user.Write(w) + if err != nil { + WriteError(w, 999 /*Internal Error*/) + log.Print(err) + return + } + } else if r.Method == "DELETE" { + //TODO delete everything else too? + //TODO how to handle making sure users really meant to delete everything? + count, err := DB.Delete(&user) + if count != 1 || err != nil { + WriteError(w, 999 /*Internal Error*/) + log.Print(err) + return + } + + WriteSuccess(w) + } + } +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..1627920 --- /dev/null +++ b/util.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "strings" +) + +func GetURLID(url string) (int64, error) { + pieces := strings.Split(strings.Trim(url, "/"), "/") + return strconv.ParseInt(pieces[len(pieces)-1], 10, 0) +} + +func GetURLPieces(url string, format string, a ...interface{}) (int, error) { + url = strings.Replace(url, "/", " ", -1) + format = strings.Replace(format, "/", " ", -1) + return fmt.Sscanf(url, format, a...) +} + +func WriteSuccess(w http.ResponseWriter) { + fmt.Fprint(w, "{}") +}