diff --git a/.gitignore b/.gitignore index b25c15b..9bb4877 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *~ +*.exe diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a7db419 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,4 @@ +language: go + +go: + - 1.3.1 \ No newline at end of file diff --git a/auth.go b/auth.go deleted file mode 100644 index 4d6079d..0000000 --- a/auth.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "net/http" - - "github.com/gorilla/context" - "github.com/lavab/api/db" - "github.com/lavab/api/dbutils" - "github.com/lavab/api/utils" -) - -// AuthWrapper is an auth middleware using the "Auth" header -// The session object gets saved in the gorilla/context map, use context.Get("session") to fetch it -func AuthWrapper(next handleFunc) handleFunc { - return func(w http.ResponseWriter, r *http.Request) { - authToken := r.Header.Get("Auth") - if authToken == "" { - utils.ErrorResponse(w, 401, "Missing auth token", "") - return - } - session, ok := dbutils.GetSession(authToken) - if !ok { - utils.ErrorResponse(w, 401, "Invalid auth token", "") - return - } - if session.HasExpired() { - utils.ErrorResponse(w, 419, "Authentication token has expired", "Session has expired on "+session.ExpDate) - db.Delete("sessions", session.ID) - return - } - - context.Set(r, "session", session) - next(w, r) - } -} diff --git a/db/crud.go b/db/crud.go deleted file mode 100644 index 0091ba2..0000000 --- a/db/crud.go +++ /dev/null @@ -1,68 +0,0 @@ -package db - -import ( - "errors" - "log" - - r "github.com/dancannon/gorethink" -) - -// TODO: throw custom errors - -// Insert performs an insert operation for any map[string]interface{} or struct -func Insert(table string, data interface{}) error { - return insertHelper(table, data, "error") -} - -// Update performs an update operation for any map[string]interface{} or struct -func Update(table string, data interface{}) error { - return insertHelper(table, data, "update") -} - -// Delete deletes a database item based on id -func Delete(table string, id string) error { - _, err := r.Table(table).Get(id).Delete().RunWrite(config.Session) - if err != nil { - log.Fatalf("Couldn't delete [%s] in table [%s]\n", id, table) - } - return nil -} - -// Get fetches a database object with a specific id -func Get(table string, id string) (*r.Cursor, error) { - if response, err := r.Table(table).Get(id).Run(config.Session); err == nil { - return response, nil - } - return nil, errors.New("Item not found") -} - -// GetAll fetches all items in the table that satisfy item[index] == value -// TODO: find out how to match on nested keys -func GetAll(table string, index string, value interface{}) (*r.Cursor, error) { - if response, err := r.Table(table).GetAllByIndex(index, value).Run(config.Session); err == nil { - return response, nil - } - return nil, errors.New("Not found") -} - -// GetByID is an alias for Get -var GetByID = Get - -// GetByIndex is an alias for GetAll -var GetByIndex = GetAll - -// Remove is an alias for Delete -var Remove = Delete - -// Rm is an alias for Delete -var Rm = Delete - -// insertHelper adds an interface{} to the database. Helper func for db.Insert and db.Update -func insertHelper(table string, data interface{}, conflictResolution string) error { - // TODO check out the RunWrite result, conflict errors are reported there - _, err := r.Table(table).Insert(data, r.InsertOpts{Conflict: conflictResolution}).RunWrite(config.Session) - if err != nil { - log.Fatalln("Database insert operation failed. Data:\n", data) - } - return nil -} diff --git a/db/default_crud.go b/db/default_crud.go new file mode 100644 index 0000000..e9bf088 --- /dev/null +++ b/db/default_crud.go @@ -0,0 +1,229 @@ +package db + +import ( + "github.com/dancannon/gorethink" +) + +// Default contains the basic implementation of the gorethinkCRUD interface +type Default struct { + table string + db string + session *gorethink.Session +} + +// NewCRUDTable sets up a new Default struct +func NewCRUDTable(session *gorethink.Session, db, table string) *Default { + return &Default{ + db: db, + table: table, + session: session, + } +} + +// GetTableName returns table's name +func (d *Default) GetTableName() string { + return d.table +} + +// GetDBName returns database's name +func (d *Default) GetDBName() string { + return d.db +} + +// GetTable returns the table as a gorethink.Term +func (d *Default) GetTable() gorethink.Term { + return gorethink.Table(d.table) +} + +// Insert inserts a document into the database +func (d *Default) Insert(data interface{}) error { + _, err := d.GetTable().Insert(data).RunWrite(d.session) + if err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// Update performs an update on an existing resource according to passed data +func (d *Default) Update(data interface{}) error { + _, err := d.GetTable().Update(data).RunWrite(d.session) + if err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// UpdateID performs an update on an existing resource with ID that equals the id argument +func (d *Default) UpdateID(id string, data interface{}) error { + _, err := d.GetTable().Get(id).Update(data).RunWrite(d.session) + if err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// Delete deletes resources that match the passed filter +func (d *Default) Delete(pred interface{}) error { + _, err := d.GetTable().Filter(pred).Delete().RunWrite(d.session) + if err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// DeleteID deletes a resource with specified ID +func (d *Default) DeleteID(id string) error { + _, err := d.GetTable().Get(id).Delete().RunWrite(d.session) + if err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// Find searches for a resource in the database and then returns a cursor +func (d *Default) Find(id string) (*gorethink.Cursor, error) { + cursor, err := d.GetTable().Get(id).Run(d.session) + if err != nil { + return nil, NewDatabaseError(d, err, "") + } + + return cursor, nil +} + +// FindFetchOne searches for a resource and then unmarshals the first row into value +func (d *Default) FindFetchOne(id string, value interface{}) error { + cursor, err := d.Find(id) + if err != nil { + return err + } + + if err := cursor.One(value); err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// FindBy is an utility for fetching values if they are stored in a key-value manenr. +func (d *Default) FindBy(key string, value interface{}) (*gorethink.Cursor, error) { + filterMap := map[string]interface{}{ + key: value, + } + + cursor, err := d.GetTable().Filter(filterMap).Run(d.session) + if err != nil { + return nil, NewDatabaseError(d, err, "") + } + + return cursor, nil +} + +// FindByAndFetch retrieves a value by key and then fills results with the result. +func (d *Default) FindByAndFetch(key string, value interface{}, results interface{}) error { + cursor, err := d.FindBy(key, value) + if err != nil { + return err + } + + if err := cursor.All(results); err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// FindByFetchOne retrieves a value by key and then fills result with the first row of the result +func (d *Default) FindByAndFetchOne(key string, value interface{}, result interface{}) error { + cursor, err := d.FindBy(key, value) + if err != nil { + return err + } + + if err := cursor.One(result); err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// Where allows filtering with multiple fields +func (d *Default) Where(filter map[string]interface{}) (*gorethink.Cursor, error) { + cursor, err := d.GetTable().Filter(filter).Run(d.session) + if err != nil { + return nil, NewDatabaseError(d, err, "") + } + + return cursor, nil +} + +// WhereAndFetch filters with multiple fields and then fills results with all found resources +func (d *Default) WhereAndFetch(filter map[string]interface{}, results interface{}) error { + cursor, err := d.Where(filter) + if err != nil { + return err + } + + if err := cursor.All(results); err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// WhereAndFetchOne filters with multiple fields and then fills result with the first found resource +func (d *Default) WhereAndFetchOne(filter map[string]interface{}, result interface{}) error { + cursor, err := d.Where(filter) + if err != nil { + return err + } + + if err := cursor.One(result); err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// FindByIndex filters all resources whose index is matching +func (d *Default) FindByIndex(index string, values ...interface{}) (*gorethink.Cursor, error) { + cursor, err := d.GetTable().GetAllByIndex(index, values...).Run(d.session) + if err != nil { + return nil, NewDatabaseError(d, err, "") + } + + return cursor, nil +} + +// FindByIndexFetch filters all resources whose index is matching and fills results with all found resources +func (d *Default) FindByIndexFetch(results interface{}, index string, values ...interface{}) error { + cursor, err := d.FindByIndex(index, values...) + if err != nil { + return err + } + + //now fetch the item from database + if err := cursor.All(results); err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} + +// FindByIndexFetchOne filters all resources whose index is matching and fills result with the first one found +func (d *Default) FindByIndexFetchOne(result interface{}, index string, values ...interface{}) error { + cursor, err := d.FindByIndex(index, values...) + if err != nil { + return err + } + + if err := cursor.One(result); err != nil { + return NewDatabaseError(d, err, "") + } + + return nil +} diff --git a/db/errors.go b/db/errors.go index 3ec6d80..4b651a0 100644 --- a/db/errors.go +++ b/db/errors.go @@ -1,5 +1,33 @@ package db +import ( + "fmt" +) + +type DatabaseError struct { + err error + message string + table RethinkTable +} + +func (d *DatabaseError) Error() string { + return fmt.Sprintf( + "%s - DB: %s - Table : %s - %s", + d.message, + d.table.GetDBName(), + d.table.GetTableName(), + d.err, + ) +} + +func NewDatabaseError(t RethinkTable, err error, message string) *DatabaseError { + return &DatabaseError{ + err: err, + table: t, + message: message, + } +} + type ConnectionError struct { error WrongAuthKey bool diff --git a/db/rethink_crud.go b/db/rethink_crud.go new file mode 100644 index 0000000..b04a381 --- /dev/null +++ b/db/rethink_crud.go @@ -0,0 +1,55 @@ +package db + +import ( + rethink "github.com/dancannon/gorethink" +) + +// RethinkTable contains the most basic table functions +type RethinkTable interface { + GetTableName() string + GetDBName() string +} + +// RethinkCreator contains a function to create new instances in the table +type RethinkCreator interface { + Insert(data interface{}) error +} + +// RethinkReader allows fetching resources from the database +type RethinkReader interface { + Find(id string) (*rethink.Cursor, error) + FindFetchOne(id string, value interface{}) error + + FindBy(key string, value interface{}) (*rethink.Cursor, error) + FindByAndFetch(key string, value interface{}, results interface{}) error + FindByAndFetchOne(key string, value interface{}, result interface{}) error + + Where(filter map[string]interface{}) (*rethink.Cursor, error) + WhereAndFetch(filter map[string]interface{}, results interface{}) error + WhereAndFetchOne(filter map[string]interface{}, result interface{}) error + + FindByIndex(index string, values ...interface{}) (*rethink.Cursor, error) + FindByIndexFetch(results interface{}, index string, values ...interface{}) error + FindByIndexFetchOne(result interface{}, index string, values ...interface{}) error +} + +// RethinkUpdater allows updating existing resources in the database +type RethinkUpdater interface { + Update(data interface{}) error + UpdateID(id string, data interface{}) error +} + +// RethinkDeleter allows deleting resources from the database +type RethinkDeleter interface { + Delete(pred interface{}) error + DeleteID(id string) error +} + +// RethinkCRUD is the interface that every table should implement +type RethinkCRUD interface { + RethinkCreator + RethinkReader + RethinkUpdater + RethinkDeleter + RethinkTable +} diff --git a/db/setup.go b/db/setup.go index 2fcbfb0..835a146 100644 --- a/db/setup.go +++ b/db/setup.go @@ -1,30 +1,19 @@ package db import ( - "fmt" - "log" - "os" - "time" - - r "github.com/dancannon/gorethink" + "github.com/dancannon/gorethink" ) -var config struct { - Session *r.Session - Url string - AuthKey string - Db string -} - -var dbs = []string{ - "prod", - "staging", - "dev", -} +// Publicly exported table models +var ( + Accounts *AccountsTable + Sessions *TokensTable +) -var tablesAndIndexes = map[string][]string{ - "sessions": []string{"user", "user_id"}, - "users": []string{"name"}, +// Indexes of tables in the database +var tableIndexes = map[string][]string{ + "tokens": []string{"user", "user_id"}, + "accounts": []string{"name"}, "emails": []string{"user_id"}, "drafts": []string{"user_id"}, "contacts": []string{}, @@ -33,61 +22,35 @@ var tablesAndIndexes = map[string][]string{ "keys": []string{}, } -func Init() { - config.Url = "localhost:28015" - config.AuthKey = "" - config.Db = "dev" - - if tmp := os.Getenv("RETHINKDB_URL"); tmp != "" { - config.Url = tmp - } else if tmp := os.Getenv("RETHINKDB_PORT_28015_TCP_ADDR"); tmp != "" { - config.Url = fmt.Sprintf("%s:28015", tmp) - } else { - log.Printf("No database URL specified, using %s.\n", config.Url) - } - if tmp := os.Getenv("RETHINKDB_AUTHKEY"); tmp != "" { - config.AuthKey = tmp - } else { - log.Fatalln("Variable RETHINKDB_AUTHKEY not set.") - } - if tmp := os.Getenv("API_ENV"); tmp != "" { - // TODO add check that tmp is in dbs - config.Db = tmp - } else { - log.Printf("No database specified, using %s.\n", config.Db) - } +// List of names of databases +var databaseNames = []string{ + "prod", + "staging", + "dev", +} - // Initialise databases, tables, and indexes. This might take a while if they don't exist - setupSession, err := r.Connect(r.ConnectOpts{ - Address: config.Url, - AuthKey: config.AuthKey, - MaxIdle: 10, - IdleTimeout: time.Second * 10, - }) +// Setup configures the RethinkDB server +func Setup(opts gorethink.ConnectOpts) error { + // Initialize a new setup connection + setupSession, err := gorethink.Connect(opts) if err != nil { - log.Fatalf("Error connecting to DB: %s", err) + return err } - log.Println("Creating dbs \\ tables \\ indexes") - for _, d := range dbs { - log.Println(d) - r.DbCreate(d).Run(setupSession) - for t, indexes := range tablesAndIndexes { - log.Println("› ", t) - r.Db(d).TableCreate(t).RunWrite(setupSession) + + // Create databases + for _, d := range databaseNames { + gorethink.DbCreate(d).Run(setupSession) + + // Create tables + for t, indexes := range tableIndexes { + gorethink.Db(d).TableCreate(t).RunWrite(setupSession) + + // Create indexes for _, index := range indexes { - log.Println("› › ", index) - r.Db(d).Table(t).IndexCreate(index).Exec(setupSession) + gorethink.Db(d).Table(t).IndexCreate(index).Exec(setupSession) } } } - setupSession.Close() - // Setting up the main session - config.Session, err = r.Connect(r.ConnectOpts{ - Address: config.Url, - AuthKey: config.AuthKey, - Database: config.Db, - MaxIdle: 10, - IdleTimeout: time.Second * 10, - }) + return setupSession.Close() } diff --git a/db/table_accounts.go b/db/table_accounts.go new file mode 100644 index 0000000..d7a1ee9 --- /dev/null +++ b/db/table_accounts.go @@ -0,0 +1,32 @@ +package db + +import ( + "github.com/lavab/api/models" +) + +// AccountsTable implements the CRUD interface for accounts +type AccountsTable struct { + RethinkCRUD +} + +// GetAccount returns an account with specified ID +func (users *AccountsTable) GetAccount(id string) (*models.Account, error) { + var result models.Account + + if err := users.FindFetchOne(id, &result); err != nil { + return nil, err + } + + return &result, nil +} + +// FindAccountByName returns an account with specified name +func (users *AccountsTable) FindAccountByName(name string) (*models.Account, error) { + var result models.Account + + if err := users.FindByIndexFetchOne(&result, "name", name); err != nil { + return nil, err + } + + return &result, nil +} diff --git a/db/table_sessions.go b/db/table_sessions.go new file mode 100644 index 0000000..a19ccb0 --- /dev/null +++ b/db/table_sessions.go @@ -0,0 +1,21 @@ +package db + +import ( + "github.com/lavab/api/models" +) + +// TokensTable implements the CRUD interface for tokens +type TokensTable struct { + RethinkCRUD +} + +// GetToken returns a token with specified name +func (s *TokensTable) GetToken(id string) (*models.Token, error) { + var result models.Token + + if err := s.FindFetchOne(id, &result); err != nil { + return nil, err + } + + return &result, nil +} diff --git a/dbutils/sessions.go b/dbutils/sessions.go deleted file mode 100644 index fec1168..0000000 --- a/dbutils/sessions.go +++ /dev/null @@ -1,22 +0,0 @@ -package dbutils - -import ( - "log" - - "github.com/lavab/api/db" - "github.com/lavab/api/models" -) - -func GetSession(id string) (*models.Session, bool) { - var result models.Session - response, err := db.Get("sessions", id) - if err == nil && response != nil && !response.IsNil() { - err := response.One(&result) - if err != nil { - log.Fatalln("[utils.GetSession] Error when unfolding cursor") - return nil, false - } - return &result, true - } - return nil, false -} diff --git a/dbutils/users.go b/dbutils/users.go deleted file mode 100644 index 438ab2e..0000000 --- a/dbutils/users.go +++ /dev/null @@ -1,36 +0,0 @@ -package dbutils - -import ( - "log" - - "github.com/lavab/api/db" - "github.com/lavab/api/models" -) - -func GetUser(id string) (*models.User, bool) { - var result models.User - response, err := db.Get("users", id) - if err == nil && !response.IsNil() { - err := response.One(&result) - if err != nil { - log.Fatalln("[utils.GetUser] Error when unfolding cursor") - return nil, false - } - return &result, true - } - return nil, false -} - -func FindUserByName(username string) (*models.User, bool) { - var result models.User - response, err := db.GetAll("users", "name", username) - if err == nil && response != nil && !response.IsNil() { - err := response.One(&result) - if err != nil { - log.Fatalln("[utils.FindUserByName] Error when unfolding cursor") - return nil, false - } - return &result, true - } - return nil, false -} diff --git a/env/config.go b/env/config.go new file mode 100644 index 0000000..e0d4bdd --- /dev/null +++ b/env/config.go @@ -0,0 +1,8 @@ +package env + +type Config struct { + BindAddress string + APIVersion string + LogFormatterType string + SessionDuration int +} diff --git a/env/env.go b/env/env.go new file mode 100644 index 0000000..9fd89bb --- /dev/null +++ b/env/env.go @@ -0,0 +1,22 @@ +package env + +import ( + "github.com/Sirupsen/logrus" + "github.com/dancannon/gorethink" + + "github.com/lavab/api/db" +) + +type Environment struct { + Log *logrus.Logger + Config *Config + Rethink *gorethink.Session + R *R +} + +type R struct { + Accounts *db.AccountsTable + Tokens *db.TokensTable +} + +var G *Environment diff --git a/main.go b/main.go index e6aaf85..d9e9e08 100644 --- a/main.go +++ b/main.go @@ -1,96 +1,229 @@ package main import ( - "fmt" - "log" + "net" "net/http" "os" - "strconv" "time" - "github.com/gorilla/mux" + "github.com/Sirupsen/logrus" + "github.com/dancannon/gorethink" + "github.com/goji/glogrus" + "github.com/namsral/flag" + "github.com/zenazn/goji/graceful" + "github.com/zenazn/goji/web" + "github.com/zenazn/goji/web/middleware" + "github.com/lavab/api/db" - "github.com/lavab/api/utils" - "github.com/stretchr/graceful" + "github.com/lavab/api/env" + "github.com/lavab/api/routes" ) // TODO: "Middleware that implements a few quick security wins" // https://github.com/unrolled/secure -const ( - cTlsFilePub = ".tls/pub" - cTlsFilePriv = ".tls/priv" - cTcpPort = 5000 - cApiVersion = "v0" +var ( + // General flags + bindAddress = flag.String("bind", ":5000", "Network address used to bind") + apiVersion = flag.String("version", "v0", "Shown API version") + logFormatterType = flag.String("log", "text", "Log formatter type. Either \"json\" or \"text\"") + sessionDuration = flag.Int("session_duration", 72, "Session duration expressed in hours") + // Database-related flags + rethinkdbURL = flag.String("rethinkdb_url", func() string { + address := os.Getenv("RETHINKDB_PORT_28015_TCP_ADDR") + if address == "" { + address = "localhost" + } + return address + ":28015" + }(), "Address of the RethinkDB database") + rethinkdbKey = flag.String("rethinkdb_key", os.Getenv("RETHINKDB_AUTHKEY"), "Authentication key of the RethinkDB database") + rethinkdbDatabase = flag.String("rethinkdb_db", func() string { + database := os.Getenv("RETHINKDB_NAME") + if database == "" { + database = "dev" + } + return database + }(), "Database name on the RethinkDB server") ) -var config struct { - Port int - PortString string - Host string - TlsAvailable bool - RootJSON string -} - -func init() { - config.Port = cTcpPort - config.Host = "" - config.TlsAvailable = false - config.RootJSON = rootResponseString() // this avoids an import cycle and also improves perf by caching the response +func main() { + // Parse the flags + flag.Parse() - if tmp := os.Getenv("API_PORT"); tmp != "" { - tmp2, err := strconv.Atoi(tmp) - if err != nil { - config.Port = tmp2 - } - log.Println("Running on non-default port", config.Port) - } - config.PortString = fmt.Sprintf(":%d", config.Port) + // Set up a new logger + log := logrus.New() - if utils.FileExists(cTlsFilePub) && utils.FileExists(cTlsFilePriv) { - config.TlsAvailable = true - log.Println("Imported TLS cert/key successfully.") - } else { - log.Printf("TLS cert (%s) and key (%s) not found, serving plain HTTP.\n", cTlsFilePub, cTlsFilePriv) + // Set the formatter depending on the passed flag's value + if *logFormatterType == "text" { + log.Formatter = &logrus.TextFormatter{} + } else if *logFormatterType == "json" { + log.Formatter = &logrus.JSONFormatter{} } - // Set up RethinkDB - go db.Init() -} - -func main() { - setupAndRun() -} - -func setupAndRun() { - r := mux.NewRouter() - - if config.TlsAvailable { - r = r.Schemes("https").Subrouter() + // Set up the database + rethinkOpts := gorethink.ConnectOpts{ + Address: *rethinkdbURL, + AuthKey: *rethinkdbKey, + MaxIdle: 10, + IdleTimeout: time.Second * 10, } - if tmp := os.Getenv("API_HOST"); tmp != "" { - r = r.Host(tmp).Subrouter() + err := db.Setup(rethinkOpts) + if err != nil { + log.WithFields(logrus.Fields{ + "error": err, + }).Fatal("Unable to set up the database") } - for _, rt := range publicRoutes { - r.HandleFunc(rt.Path, rt.HandleFunc).Methods(rt.Method) + // Initialize the actual connection + rethinkOpts.Database = *rethinkdbDatabase + rethinkSession, err := gorethink.Connect(rethinkOpts) + if err != nil { + log.WithFields(logrus.Fields{ + "error": err, + }).Fatal("Unable to connect to the database") } - for _, rt := range authRoutes { - r.HandleFunc(rt.Path, AuthWrapper(rt.HandleFunc)).Methods(rt.Method) + // Initialize the tables + tables := &env.R{ + Accounts: &db.AccountsTable{ + RethinkCRUD: db.NewCRUDTable( + rethinkSession, + rethinkOpts.Database, + "accounts", + ), + }, + Tokens: &db.TokensTable{ + RethinkCRUD: db.NewCRUDTable( + rethinkSession, + rethinkOpts.Database, + "tokens", + ), + }, } - srv := &graceful.Server{ - Timeout: 10 * time.Second, - Server: &http.Server{ - Addr: config.PortString, - Handler: r, + // Create a new goji mux + mux := web.New() + + // Include the most basic middlewares: + // - RequestID assigns an unique ID for each request in order to identify errors. + // - Glogrus logs each request + // - Recoverer prevents panics from crashing the API + // - AutomaticOptions automatically responds to OPTIONS requests + mux.Use(middleware.RequestID) + mux.Use(glogrus.NewGlogrus(log, "api")) + mux.Use(middleware.Recoverer) + mux.Use(middleware.AutomaticOptions) + + // Set up an auth'd mux + auth := web.New() + mux.Use(routes.AuthMiddleware) + + // Index route + mux.Get("/", routes.Hello) + + // Accounts + auth.Get("/accounts", routes.AccountsList) + mux.Post("/accounts", routes.AccountsCreate) + auth.Get("/accounts/:id", routes.AccountsGet) + auth.Put("/accounts/:id", routes.AccountsUpdate) + auth.Delete("/accounts/:id", routes.AccountsDelete) + auth.Post("/accounts/:id/wipe-data", routes.AccountsWipeData) + auth.Get("/accounts/:id/sessions", routes.AccountsSessionsList) + + // Tokens + auth.Get("/tokens", routes.TokensGet) + auth.Post("/tokens", routes.TokensCreate) + auth.Delete("/tokens", routes.TokensDelete) + + // Threads + auth.Get("/threads", routes.ThreadsList) + auth.Get("/threads/:id", routes.ThreadsGet) + auth.Put("/threads/:id", routes.ThreadsUpdate) + + // Emails + auth.Get("/emails", routes.EmailsList) + auth.Post("/emails", routes.EmailsCreate) + auth.Get("/emails/:id", routes.EmailsGet) + auth.Put("/emails/:id", routes.EmailsUpdate) + auth.Delete("/emails/:id", routes.EmailsDelete) + + // Labels + auth.Get("/labels", routes.LabelsList) + auth.Post("/labels", routes.LabelsCreate) + auth.Get("/labels/:id", routes.LabelsGet) + auth.Put("/labels/:id", routes.LabelsUpdate) + auth.Delete("/labels/:id", routes.LabelsDelete) + + // Contacts + auth.Get("/contacts", routes.ContactsList) + auth.Post("/contacts", routes.ContactsCreate) + auth.Get("/contacts/:id", routes.ContactsGet) + auth.Put("/contacts/:id", routes.ContactsUpdate) + auth.Delete("/contacts/:id", routes.ContactsDelete) + + // Keys + auth.Post("/keys", routes.KeysCreate) + mux.Get("/keys/:id", routes.KeysGet) + auth.Post("/keys/:id/vote", routes.KeysVote) + + // Merge the muxes + mux.Handle("/", auth) + + // Compile the routes + mux.Compile() + + // Make the mux handle every request + http.Handle("/", mux) + + // Set up a new environment object + env.G = &env.Environment{ + Log: log, + Config: &env.Config{ + BindAddress: *bindAddress, + APIVersion: *apiVersion, + LogFormatterType: *logFormatterType, + SessionDuration: *sessionDuration, }, + Rethink: rethinkSession, + R: tables, + } + + // Log that we're starting the server + log.WithFields(logrus.Fields{ + "address": *bindAddress, + }).Info("Starting the HTTP server") + + // Initialize the goroutine listening to signals passed to the app + graceful.HandleSignals() + + // Pre-graceful shutdown event + graceful.PreHook(func() { + log.Info("Received a singnal, stopping the application") + }) + + // Post-shutdown event + graceful.PostHook(func() { + log.Info("Stopped the application") + }) + + // Listen to the passed address + listener, err := net.Listen("tcp", *bindAddress) + if err != nil { + log.WithFields(logrus.Fields{ + "error": err, + "address": *bindAddress, + }).Fatal("Cannot set up a TCP listener") } - if config.TlsAvailable { - log.Fatal(srv.ListenAndServeTLS(cTlsFilePub, cTlsFilePriv)) - } else { - log.Fatal(srv.ListenAndServe()) + // Start the listening + err = graceful.Serve(listener, http.DefaultServeMux) + if err != nil { + // Don't use .Fatal! We need the code to shut down properly. + log.Error(err) } + + // If code reaches this place, it means that it was forcefully closed. + + // Wait until open connections close. + graceful.Wait() } diff --git a/models/user.go b/models/account.go similarity index 57% rename from models/user.go rename to models/account.go index 5871df3..8d9fff0 100644 --- a/models/user.go +++ b/models/account.go @@ -1,15 +1,15 @@ package models -import "github.com/lavab/api/models/base" - -// User stores essential data for a Lavaboom user, and is thus not encrypted. -type User struct { - base.Resource +// Account stores essential data for a Lavaboom user, and is thus not encrypted. +type Account struct { + Resource // Billing is a struct containing billing information. + // TODO Work in progress Billing BillingData `json:"billing" gorethink:"billing"` - // Password is the actual user password, hashed using bcrypt. + // Password is the password used to login to the account. + // It's hashed and salted using a cryptographically strong method (bcrypt|scrypt). Password string `json:"-" gorethink:"password"` // PgpExpDate is an RFC3339-encoded string containing the expiry date of the user's public key @@ -21,10 +21,16 @@ type User struct { // PgpPublicKey is a copy of the user's current public key. It can also be found in the 'keys' db. PgpPublicKey string `json:"pgp_public_key" gorethink:"pgp_public_key"` - // Settings is a struct containing app configuration data. + // Settings contains data needed to customize the user experience. + // TODO Work in progress Settings SettingsData `json:"settings" gorethink:"settings"` - // Type is the user type (free, beta, premium, etc) + // Type is the account type. + // Examples (work in progress): + // * beta: while in beta these are full accounts; after beta, these are normal accounts with special privileges + // * std: standard, free account + // * premium: premium account + // * superuser: Lavaboom staff Type string `json:"type" gorethink:"type"` } diff --git a/models/base/expiring.go b/models/base/expiring.go deleted file mode 100644 index c25c1f6..0000000 --- a/models/base/expiring.go +++ /dev/null @@ -1,25 +0,0 @@ -package base - -import ( - "log" - "time" -) - -// Expiring is a base struct for resources that expires e.g. sessions. -type Expiring struct { - // ExpDate is the RFC3339-encoded time when the resource will expire. - ExpDate string `json:"exp_date" gorethink:"exp_date"` -} - -// HasExpired returns true if the resource has expired (or if the ExpDate string is badly formatted) -func (e *Expiring) HasExpired() bool { - t, err := time.Parse(time.RFC3339, e.ExpDate) - if err != nil { - log.Println("Bad format! The expiry date is not RFC3339-formatted.", err) - return true - } - if time.Now().UTC().After(t) { - return true - } - return false -} diff --git a/models/base/resource.go b/models/base/resource.go deleted file mode 100644 index 315bd0c..0000000 --- a/models/base/resource.go +++ /dev/null @@ -1,42 +0,0 @@ -package base - -import "github.com/lavab/api/utils" - -// Resource is the base struct for every resource that needs to be saved to db and marshalled with json. -type Resource struct { - // ID is the resources ID, used as a primary key by the db. - ID string `json:"id" gorethink:"id"` - - // DateChanged is an RFC3339-encoded time string that lets clients poll whether a resource has changed. - DateChanged string `json:"date_changed" gorethink:"date_changed"` - - // DateCreated is an RFC3339-encoded string that stores the creation date of a resource. - DateCreated string `json:"date_created" gorethink:"date_created"` - - // Name is a human-friendly description of the resource. - // Sometimes it can be essential to the resource, e.g. the `User.Name` field. - Name string `json:"name" gorethink:"name,omitempty"` - - // UserID is the ID of the user to which this resource belongs. - UserID string `json:"user_id" gorethink:"user_id"` -} - -// MakeResource creates a new Resource object with sane defaults. -func MakeResource(userID, name string) Resource { - t := utils.TimeNowString() - return Resource{ - ID: utils.UUID(), - DateChanged: t, - DateCreated: t, - Name: name, - UserID: userID, - } -} - -// Touch sets r.DateChanged to the current time -// It returns the object it's modifying to allow chaining for shorter code: -// r.Touch().PerformSomeOp(args) -func (r *Resource) Touch() *Resource { - r.DateChanged = utils.TimeNowString() - return r -} diff --git a/models/base/encrypted.go b/models/base_encrypted.go similarity index 59% rename from models/base/encrypted.go rename to models/base_encrypted.go index 5671d44..359f066 100644 --- a/models/base/encrypted.go +++ b/models/base_encrypted.go @@ -1,17 +1,12 @@ -package base +package models -import ( - "log" - "strings" -) - -// Encrypted is the base struct for PGP-encrypted resources +// Encrypted is the base struct for PGP-encrypted resources. type Encrypted struct { // Encoding tells the reader how to decode the data; can be "json", "protobuf", maybe more in the future Encoding string `json:"encoding" gorethink:"encoding"` // PgpFingerprints contains the fingerprints of the PGP public keys used to encrypt the data. - PgpFingerprints string `json:"pgp_fingerprints" gorethink:"pgp_fingerprints"` + PgpFingerprints []string `json:"pgp_fingerprints" gorethink:"pgp_fingerprints"` // Data is the raw, PGP-encrypted data Data []byte `json:"raw" gorethink:"raw"` @@ -28,18 +23,3 @@ type Encrypted struct { // Schemas with different minor versions should be compatible. VersionMinor int `json:"version_minor" gorethink:"version_minor"` } - -// IsCompatibleWith checks whether the schema versions of two Encrypted objects are compatible -func (e *Encrypted) IsCompatibleWith(res *Encrypted) bool { - if e == nil || res == nil { - log.Printf("[models.IsCompatibleWith] %+v or %+v were nil\n", e, res) - return false - } - if strings.ToLower(e.Schema) != strings.ToLower(res.Schema) { - return false - } - if e.VersionMajor == res.VersionMajor { - return true - } - return false -} diff --git a/models/base_expiring.go b/models/base_expiring.go new file mode 100644 index 0000000..fe02c26 --- /dev/null +++ b/models/base_expiring.go @@ -0,0 +1,29 @@ +package models + +import ( + "time" +) + +// Expiring is a base struct for resources that expires e.g. sessions. +type Expiring struct { + // ExpiryDate indicates when an object will expire + ExpiryDate time.Time `json:"expiry_date" gorethink:"expiry_date"` +} + +// Expired checks whether an object has expired. It returns true if ExpiryDate is in the past. +func (e *Expiring) Expired() bool { + if time.Now().UTC().After(e.ExpiryDate) { + return true + } + return false +} + +// ExpireAfterNHours sets the expiry date to time.Now().UTC() + n hours +func (e *Expiring) ExpireAfterNHours(n int) { + e.ExpiryDate = time.Now().UTC().Add(time.Duration(n) * time.Hour) +} + +// ExpireSoon sets the expiry date to something in the near future. +func (e *Expiring) ExpireSoon() { + e.ExpiryDate = time.Now().UTC().Add(time.Duration(2) * time.Minute) +} diff --git a/models/base_resource.go b/models/base_resource.go new file mode 100644 index 0000000..ef97bd9 --- /dev/null +++ b/models/base_resource.go @@ -0,0 +1,45 @@ +package models + +import ( + "time" + + "github.com/dchest/uniuri" +) + +// Resource is the base type for API resources. +type Resource struct { + // ID is the resources ID, used as a primary key by the db. + // For some resources (invites, auth tokens) this is also the data itself. + ID string `json:"id" gorethink:"id"` + + // DateCreated is, shockingly, the time when the resource was created. + DateCreated time.Time `json:"date_created" gorethink:"date_created"` + + // DateModified records the time of the last change of the resource. + DateModified time.Time `json:"date_modified" gorethink:"date_modified"` + + // Name is the human-friendly name of the resource. It can either be essential (e.g. Account.Name) or optional. + Name string `json:"name" gorethink:"name,omitempty"` + + // Owner is the ID of the account that owns this resource. + Owner string `json:"owner" gorethink:"owner"` +} + +// MakeResource creates a new Resource object with sane defaults. +func MakeResource(ownerID, name string) Resource { + t := time.Now() + return Resource{ + ID: uniuri.NewLen(uniuri.UUIDLen), + DateModified: t, + DateCreated: t, + Name: name, + Owner: ownerID, + } +} + +// Touch sets the time the resource was last modified to time.Now(). +// For convenience (e.g. chaining) it also returns the resource pointer. +func (r *Resource) Touch() *Resource { + r.DateModified = time.Now() + return r +} diff --git a/models/contact.go b/models/contact.go index e07bc05..c0eaa7e 100644 --- a/models/contact.go +++ b/models/contact.go @@ -1,12 +1,7 @@ package models -import "github.com/lavab/api/models/base" - // Contact is the data model for a contact. type Contact struct { - base.Encrypted - base.Resource - - // Picture is a profile picture - Picture Avatar `json:"picture" gorethink:"picture"` + Encrypted + Resource } diff --git a/models/email.go b/models/email.go index a24eec6..610570c 100644 --- a/models/email.go +++ b/models/email.go @@ -1,18 +1,16 @@ package models -import "github.com/lavab/api/models/base" - // Email is the cornerstone of our application. // TODO mime info type Email struct { - base.Resource + Resource // AttachmentsIDs is a slice of the FileIDs associated with this email // For uploading attachments see `POST /upload` AttachmentIDs []string `json:"attachments" gorethink:"attachments"` // Body contains all the data needed to send this email - Body base.Encrypted `json:"body" gorethink:"body"` + Body Encrypted `json:"body" gorethink:"body"` LabelIDs []string `json:"label_ids" gorethink:"label_ids"` @@ -21,7 +19,7 @@ type Email struct { // Headers []string // Body string // Snippet string - Preview base.Encrypted `json:"preview" gorethink:"preview"` + Preview Encrypted `json:"preview" gorethink:"preview"` // ThreadID ThreadID string `json:"thread_id" gorethink:"thread_id"` diff --git a/models/file.go b/models/file.go index 273ca0f..9e1eb33 100644 --- a/models/file.go +++ b/models/file.go @@ -1,28 +1,14 @@ package models -import "github.com/lavab/api/models/base" - // File is an encrypted file stored by Lavaboom type File struct { - base.Encrypted - base.Resource + Encrypted + Resource // Mime is the Internet media type of the file - // Check out: http://en.wikipedia.org/wiki/Internet_media_type + // Format: "type/subtype" – more info: en.wikipedia.org/wiki/Internet_media_type Mime string `json:"mime" gorethink:"mime"` // Size is the size of the file in bytes i.e. len(file.Data) Size int `json:"size" gorethink:"size"` - - // Type is the generic type of the file - // Possible values: `file`, `audio`, `video`, `pdf`, `text`, `binary` - Type string `json:"type" gorethink:"type"` -} - -// Avatar is a picture used to identify contacts -type Avatar struct { - base.Resource - Large File `json:"data" gorethink:"data"` - Medium File `json:"thumb_small" gorethink:"thumb_small"` - Small File `json:"thumb_large" gorethink:"thumb_large"` } diff --git a/models/label.go b/models/label.go index f953347..b36320a 100644 --- a/models/label.go +++ b/models/label.go @@ -1,13 +1,26 @@ package models -import "github.com/lavab/api/models/base" +// TODO: nested labels? +// Label is what IMAP calls folders, some providers call tags, and what we (and Gmail) call labels. +// It's both a simple way for users to organise their emails, but also a way to provide classic folder +// functionality (inbox, spam, drafts, etc). +// Examples: +// * star an email: add the "starred" label +// * archive an email: remove the "inbox" label +// * delete an email: apply the "deleted" label (and cue for deletion) type Label struct { - base.Resource - EmailsUnread int `json:"emails_unread" gorethink:"emails_unread"` - EmailsTotal int `json:"emails_total" gorethink:"emails_total"` - Hidden bool `json:"hidden" gorethink:"hidden"` - Immutable bool `json:"immutable" gorethink:"immutable"` - ThreadsUnread int `json:"threads_unread"` - ThreadsTotal int `json:"threads_total"` + Resource + + // Builtin indicates whether a label is created/needed by the system. + // Examples: inbox, trash, spam, drafts, starred, etc. + Builtin bool `json:"builtin" gorethink:"builtin"` + + // EmailsUnread is the number of unread emails that have a particular label applied. + // Storing this for each label eliminates the need of db lookups for this commonly needed information. + EmailsUnread int `json:"emails_unread" gorethink:"emails_unread"` + + // EmailsTotal is the number of emails that have a particular label applied. + // Storing this for each label eliminates the need of db lookups for this commonly needed information. + EmailsTotal int `json:"emails_total" gorethink:"emails_total"` } diff --git a/models/session.go b/models/session.go deleted file mode 100644 index 4b39d46..0000000 --- a/models/session.go +++ /dev/null @@ -1,24 +0,0 @@ -package models - -import ( - "log" - "net/http" - - "github.com/gorilla/context" - "github.com/lavab/api/models/base" -) - -// Session TODO -type Session struct { - base.Expiring - base.Resource -} - -// CurrentSession returns the current request's session object -func CurrentSession(r *http.Request) *Session { - session, ok := context.Get(r, "session").(*Session) - if !ok { - log.Fatalln("Session data in gorilla/context was not found or malformed.") - } - return session -} diff --git a/models/thread.go b/models/thread.go index 2949f37..f423a27 100644 --- a/models/thread.go +++ b/models/thread.go @@ -1,19 +1,22 @@ package models -import "github.com/lavab/api/models/base" - -// Thread is the data model for a conversation. +// Thread is the data model for a list of emails, usually making up a conversation. type Thread struct { - base.Resource + Resource + + // Emails is a list of email IDs belonging to this thread + Emails []string `json:"emails" gorethink:"emails"` - // Emails is a slice containing email IDs. - // Ideally the array should be ordered by creation date, newest first - EmailIDs []string `json:"email_ids" gorethink:"email_ids"` + // Labels is a list of label IDs assigned to this thread. + // Note that emails lack this functionality. This way you can't only archive part of a thread. + Labels []string `json:"labels" gorethink:"labels"` // Members is a slice containing userIDs or email addresses for all members of the thread Members []string `json:"members" gorethink:"members"` - // Preview contains the thread details to be shown in the list of emails - // This should be - Preview base.Encrypted `json:"preview" gorethink:"preview"` + // Snippet is a bit of text from the conversation, for context. It's only visible to the user. + Snippet Encrypted `json:"snippet" gorethink:"snippet"` + + // Subject is the subject of the thread. + Subject string `json:"subject" gorethink:"subject"` } diff --git a/models/token.go b/models/token.go new file mode 100644 index 0000000..2a824a4 --- /dev/null +++ b/models/token.go @@ -0,0 +1,37 @@ +package models + +// Token is a volatile, unique object. It can be used for user authentication, confirmations, invites, etc. +type Token struct { + Expiring + Resource + + // Type describes the token's purpose: auth, invite, upgrade, etc. + Type string `json:"type" gorethink:"type"` +} + +// MakeToken creates a generic token. +func MakeToken(accountID, _type string, nHours int) Token { + out := Token{ + Resource: MakeResource(accountID, ""), + Type: _type, + } + out.ExpireAfterNHours(nHours) + return out +} + +// Invalidate invalidates a token by adding a period (".") at the beginning of its type. +// It also shortens its expiration time. +func (t *Token) Invalidate() { + t.Type = "." + t.Type + t.ExpireSoon() +} + +// MakeAuthToken creates an authentication token, valid for a limited time. +func MakeAuthToken(accountID string) Token { + return MakeToken(accountID, "auth", 80) +} + +// MakeInviteToken creates an invitation to create an account. +func MakeInviteToken(accountID string) Token { + return MakeToken(accountID, "invite", 240) +} diff --git a/routes.go b/routes.go deleted file mode 100644 index b958a8d..0000000 --- a/routes.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - - "github.com/lavab/api/routes" -) - -type handleFunc func(w http.ResponseWriter, r *http.Request) - -type route struct { - Path string `json:"path"` - HandleFunc handleFunc `json:"-"` - Method string `json:"method"` - Description string `json:"desc"` -} - -var publicRoutes = []route{ - route{"/", listRoutes, "GET", "List of public and auth methods"}, - route{"/keys/{id}", routes.Key, "GET", ""}, - route{"/login", routes.Login, "POST", ""}, - route{"/signup", routes.Signup, "POST", ""}, -} - -var authRoutes = []route{ - route{"/logout", routes.Logout, "DELETE", "Destroys the current session"}, - route{"/me", routes.Me, "GET", "Fetch profile data for the current user"}, - route{"/me", routes.UpdateMe, "PUT", "Update data for the current user (settings, billing data, password, etc.)"}, - route{"/me/sessions", routes.Sessions, "GET", "Lists all the active sessions for the current user"}, - route{"/me/wipe-user-data", routes.WipeUserData, "DELETE", "Deletes all personal data of the user, except for basic profile information and billing status"}, - route{"/me/delete-account", routes.DeleteAccount, "DELETE", "Permanently deletes the user account"}, - route{"/threads", routes.Threads, "GET", "List email threads for the current user"}, - route{"/threads/{id}", routes.Thread, "GET", "Fetch a specific email thread"}, - route{"/threads/{id}", routes.UpdateThread, "PUT", "Update an email thread"}, - route{"/emails", routes.Emails, "GET", "List all emails for the current user"}, - route{"/emails", routes.CreateEmail, "POST", "Create and send an email"}, - route{"/emails/{id}", routes.Email, "GET", "Fetch a specific email"}, - route{"/emails/{id}", routes.UpdateEmail, "PUT", "Update a specific email (label, archive, etc)"}, - route{"/emails/{id}", routes.DeleteEmail, "DELETE", "Delete an email"}, - route{"/labels", routes.Labels, "GET", "List labels for the current user"}, - route{"/labels", routes.CreateLabel, "POST", "Create a new label"}, - route{"/labels/{id}", routes.Label, "GET", "Fetch a specific label"}, - route{"/labels/{id}", routes.UpdateLabel, "PUT", "Update a label"}, - route{"/labels/{id}", routes.DeleteLabel, "DELETE", "Delete a label"}, - route{"/contacts", routes.Contacts, "GET", "List all contacts for the current user"}, - route{"/contacts", routes.CreateContact, "POST", "Create a new contact"}, - route{"/contacts/{id}", routes.Contact, "GET", "Fetch a specific contact"}, - route{"/contacts/{id}", routes.UpdateContact, "PUT", "Update a contact"}, - route{"/contacts/{id}", routes.DeleteContact, "DELETE", "Delete a contact"}, - route{"/keys", routes.SubmitKey, "POST", "Submit a key to the Lavaboom private server"}, - route{"/keys/{id}", routes.VoteKey, "POST", "Vote or flag a key"}, -} - -func listRoutes(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, config.RootJSON) -} - -func rootResponseString() string { - tmp, err := json.Marshal( - map[string]interface{}{ - "message": "Lavaboom API", - "docs_url": "http://lavaboom.readme.io/", - "version": cApiVersion, - "routes": map[string]interface{}{ - "public": publicRoutes, - "auth": authRoutes, - }}) - if err != nil { - log.Fatalln("Error! Couldn't marshal JSON.", err) - } - return string(tmp) -} diff --git a/routes/accounts.go b/routes/accounts.go new file mode 100644 index 0000000..2103fa3 --- /dev/null +++ b/routes/accounts.go @@ -0,0 +1,229 @@ +package routes + +import ( + "net/http" + + "github.com/Sirupsen/logrus" + "github.com/zenazn/goji/web" + + "github.com/lavab/api/env" + "github.com/lavab/api/models" + "github.com/lavab/api/utils" +) + +// AccountsListResponse contains the result of the AccountsList request. +type AccountsListResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// AccountsList returns a list of accounts visible to an user +func AccountsList(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &AccountsListResponse{ + Success: false, + Message: "Method not implemented", + }) +} + +// AccountsCreateRequest contains the input for the AccountsCreate endpoint. +type AccountsCreateRequest struct { + Username string `json:"username" schema:"username"` + Password string `json:"password" schema:"password"` +} + +// AccountsCreateResponse contains the output of the AccountsCreate request. +type AccountsCreateResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Account *models.Account `json:"account,omitempty"` +} + +// AccountsCreate creates a new account in the system. +func AccountsCreate(w http.ResponseWriter, r *http.Request) { + // Decode the request + var input AccountsCreateRequest + err := utils.ParseRequest(r, input) + if err != nil { + env.G.Log.WithFields(logrus.Fields{ + "error": err, + }).Warn("Unable to decode a request") + + utils.JSONResponse(w, 409, &AccountsCreateResponse{ + Success: false, + Message: "Invalid input format", + }) + return + } + + // Ensure that the user with requested username doesn't exist + if _, err := env.G.R.Accounts.FindAccountByName(input.Username); err != nil { + utils.JSONResponse(w, 409, &AccountsCreateResponse{ + Success: false, + Message: "Username already exists", + }) + return + } + + // Try to hash the password + hash, err := utils.BcryptHash(input.Password) + if err != nil { + utils.JSONResponse(w, 500, &AccountsCreateResponse{ + Success: false, + Message: "Internal server error - AC/CR/01", + }) + + env.G.Log.WithFields(logrus.Fields{ + "error": err, + }).Error("Unable to hash a password") + return + } + + // TODO: sanitize user name (i.e. remove caps, periods) + + // Create a new user object + account := &models.Account{ + Resource: models.MakeResource("", input.Username), + Password: string(hash), + } + + // Try to save it in the database + if err := env.G.R.Accounts.Insert(account); err != nil { + utils.JSONResponse(w, 500, &AccountsCreateResponse{ + Success: false, + Message: "Internal server error - AC/CR/02", + }) + + env.G.Log.WithFields(logrus.Fields{ + "error": err, + }).Error("Could not insert an user to the database") + return + } + + utils.JSONResponse(w, 201, &AccountsCreateResponse{ + Success: true, + Message: "A new account was successfully created", + Account: account, + }) +} + +// AccountsGetResponse contains the result of the AccountsGet request. +type AccountsGetResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + User *models.Account `json:"user,omitempty"` +} + +// AccountsGet returns the information about the specified account +func AccountsGet(c *web.C, w http.ResponseWriter, r *http.Request) { + // Get the account ID from the request + id, ok := c.URLParams["id"] + if !ok { + utils.JSONResponse(w, 409, &AccountsGetResponse{ + Success: false, + Message: "Invalid user ID", + }) + return + } + + // Right now we only support "me" as the ID + if id != "me" { + utils.JSONResponse(w, 501, &AccountsGetResponse{ + Success: false, + Message: `Only the "me" user is implemented`, + }) + return + } + + // Fetch the current session from the database + session := c.Env["session"].(*models.Token) + + // Fetch the user object from the database + user, err := env.G.R.Accounts.GetAccount(session.Owner) + if err != nil { + // The session refers to a non-existing user + env.G.Log.WithFields(logrus.Fields{ + "id": session.ID, + "error": err, + }).Warn("Valid session referred to a removed account") + + // Try to remove the orphaned session + if err := env.G.R.Tokens.DeleteID(session.ID); err != nil { + env.G.Log.WithFields(logrus.Fields{ + "id": session.ID, + "error": err, + }).Error("Unable to remove an orphaned session") + } else { + env.G.Log.WithFields(logrus.Fields{ + "id": session.ID, + }).Info("Removed an orphaned session") + } + + utils.JSONResponse(w, 410, &AccountsGetResponse{ + Success: false, + Message: "Account disabled", + }) + return + } + + // Return the user struct + utils.JSONResponse(w, 200, &AccountsGetResponse{ + Success: true, + User: user, + }) +} + +// AccountsUpdateResponse contains the result of the AccountsUpdate request. +type AccountsUpdateResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// AccountsUpdate allows changing the account's information (password etc.) +func AccountsUpdate(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &AccountsUpdateResponse{ + Success: false, + Message: `Sorry, not implemented yet`, + }) +} + +// AccountsDeleteResponse contains the result of the AccountsDelete request. +type AccountsDeleteResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// AccountsDelete allows deleting an account. +func AccountsDelete(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &AccountsDeleteResponse{ + Success: false, + Message: `Sorry, not implemented yet`, + }) +} + +// AccountsWipeDataResponse contains the result of the AccountsWipeData request. +type AccountsWipeDataResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// AccountsWipeData allows getting rid of the all data related to the account. +func AccountsWipeData(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &AccountsWipeDataResponse{ + Success: false, + Message: `Sorry, not implemented yet`, + }) +} + +// AccountsSessionsListResponse contains the result of the AccountsSessionsList request. +type AccountsSessionsListResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// AccountsSessionsList returns a list of all opened sessions. +func AccountsSessionsList(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &AccountsSessionsListResponse{ + Success: false, + Message: `Sorry, not implemented yet`, + }) +} diff --git a/routes/actions.go b/routes/actions.go deleted file mode 100644 index 232d3a1..0000000 --- a/routes/actions.go +++ /dev/null @@ -1,16 +0,0 @@ -package routes - -import ( - "fmt" - "net/http" -) - -// WipeUserData TODO -func WipeUserData(w http.ResponseWriter, r *http.Request) { - -} - -// DeleteAccount TODO -func DeleteAccount(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") -} diff --git a/routes/contacts.go b/routes/contacts.go index b9b8da2..d0effbf 100644 --- a/routes/contacts.go +++ b/routes/contacts.go @@ -1,38 +1,77 @@ package routes import ( - "fmt" "net/http" "github.com/lavab/api/utils" ) -// Contacts TODO -func Contacts(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// ContactsListResponse contains the result of the ContactsList request. +type ContactsListResponse struct { + Success bool `json:"success"` + Message string `json:"message"` } -// CreateContact TODO -func CreateContact(w http.ResponseWriter, r *http.Request) { - reqData, err := utils.ReadJSON(r.Body) - if err == nil { - utils.FormatNotRecognizedResponse(w, err) - return - } - fmt.Println("TODO", reqData) +// ContactsList does *something* - TODO +func ContactsList(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &ContactsListResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } -// Contact TODO -func Contact(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// ContactsCreateResponse contains the result of the ContactsCreate request. +type ContactsCreateResponse struct { + Success bool `json:"success"` + Message string `json:"message"` } -// UpdateContact TODO -func UpdateContact(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// ContactsCreate does *something* - TODO +func ContactsCreate(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &ContactsCreateResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } -// DeleteContact TODO -func DeleteContact(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// ContactsGetResponse contains the result of the ContactsGet request. +type ContactsGetResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// ContactsGet does *something* - TODO +func ContactsGet(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &ContactsGetResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) +} + +// ContactsUpdateResponse contains the result of the ContactsUpdate request. +type ContactsUpdateResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// ContactsUpdate does *something* - TODO +func ContactsUpdate(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &ContactsUpdateResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) +} + +// ContactsDeleteResponse contains the result of the ContactsDelete request. +type ContactsDeleteResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// ContactsDelete does *something* - TODO +func ContactsDelete(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &ContactsDeleteResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } diff --git a/routes/emails.go b/routes/emails.go index 48ab67e..deb3dd2 100644 --- a/routes/emails.go +++ b/routes/emails.go @@ -1,52 +1,83 @@ package routes import ( - "fmt" "net/http" "github.com/lavab/api/models" "github.com/lavab/api/utils" ) -// Emails TODO -func Emails(w http.ResponseWriter, r *http.Request) { - // fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") - mock := map[string]interface{}{ - "n_items": 1, - "emails": []models.Email{}, - } - utils.JSONResponse(w, 200, mock) -} - -// CreateEmail TODO -func CreateEmail(w http.ResponseWriter, r *http.Request) { - // reqData, err := utils.ReadJSON(r.Body) - // if err != nil { - // utils.ErrorResponse(w, 400, "Couldn't parse the request body", err.Error()) - // } - // log.Println(reqData) - mock := map[string]interface{}{ - "success": true, - "created": []string{utils.UUID()}, - } - utils.JSONResponse(w, 200, mock) -} - -// Email TODO -func Email(w http.ResponseWriter, r *http.Request) { - // fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") - mock := map[string]interface{}{ - "status": "sending", - } - utils.JSONResponse(w, 200, mock) -} - -// UpdateEmail TODO -func UpdateEmail(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") -} - -// DeleteEmail TODO -func DeleteEmail(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// EmailsListResponse contains the result of the EmailsList request. +type EmailsListResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + ItemsCount int `json:"items_count,omitempty"` + Emails []*models.Email `json:"emails,omitempty"` +} + +// EmailsList sends a list of the emails in the inbox. +func EmailsList(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 200, &EmailsListResponse{ + Success: true, + ItemsCount: 1, + Emails: []*models.Email{}, + }) +} + +// EmailsCreateResponse contains the result of the EmailsCreate request. +type EmailsCreateResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Created []string `json:"created,omitempty"` +} + +// EmailsCreate sends a new email +func EmailsCreate(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 200, &EmailsCreateResponse{ + Success: true, + Created: []string{"123"}, + }) +} + +// EmailsGetResponse contains the result of the EmailsGet request. +type EmailsGetResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Status string `json:"status,omitempty"` +} + +// EmailsGet responds with a single email message +func EmailsGet(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 200, &EmailsGetResponse{ + Success: true, + Status: "sending", + }) +} + +// EmailsUpdateResponse contains the result of the EmailsUpdate request. +type EmailsUpdateResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// EmailsUpdate does *something* - TODO +func EmailsUpdate(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &EmailsUpdateResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) +} + +// EmailsDeleteResponse contains the result of the EmailsDelete request. +type EmailsDeleteResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// EmailsDelete remvoes an email from the system +func EmailsDelete(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &EmailsDeleteResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } diff --git a/routes/hello.go b/routes/hello.go new file mode 100644 index 0000000..7594175 --- /dev/null +++ b/routes/hello.go @@ -0,0 +1,24 @@ +package routes + +import ( + "net/http" + + "github.com/lavab/api/env" + "github.com/lavab/api/utils" +) + +// HelloResponse contains the result of the Hello request. +type HelloResponse struct { + Message string `json:"message"` + DocsURL string `json:"docs_url"` + Version string `json:"version"` +} + +// Hello shows basic information about the API on its frontpage. +func Hello(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 200, &HelloResponse{ + Message: "Lavaboom API", + DocsURL: "http://lavaboom.readme.io/", + Version: env.G.Config.APIVersion, + }) +} diff --git a/routes/keys.go b/routes/keys.go index 5106401..e22732a 100644 --- a/routes/keys.go +++ b/routes/keys.go @@ -1,26 +1,49 @@ package routes import ( - "fmt" "net/http" + + "github.com/lavab/api/utils" ) -// Keys TODO -func Keys(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// KeysCreateResponse contains the result of the KeysCreate request. +type KeysCreateResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// KeysCreate does *something* - TODO +func KeysCreate(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &KeysCreateResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) +} + +// KeysGetResponse contains the result of the KeysGet request. +type KeysGetResponse struct { + Success bool `json:"success"` + Message string `json:"message"` } -// SubmitKey TODO -func SubmitKey(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// KeysGet does *something* - TODO +func KeysGet(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &KeysGetResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } -// Key TODO -func Key(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// KeysVoteResponse contains the result of the KeysVote request. +type KeysVoteResponse struct { + Success bool `json:"success"` + Message string `json:"message"` } -// VoteKey TODO -func VoteKey(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// KeysVote does *something* - TODO +func KeysVote(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &KeysVoteResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } diff --git a/routes/labels.go b/routes/labels.go index 5a2e837..45d0e97 100644 --- a/routes/labels.go +++ b/routes/labels.go @@ -1,31 +1,77 @@ package routes import ( - "fmt" "net/http" + + "github.com/lavab/api/utils" ) -// Labels TODO -func Labels(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// LabelsListResponse contains the result of the LabelsList request. +type LabelsListResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// LabelsList does *something* - TODO +func LabelsList(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &LabelsListResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) +} + +// LabelsCreateResponse contains the result of the LabelsCreate request. +type LabelsCreateResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// LabelsCreate does *something* - TODO +func LabelsCreate(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &LabelsCreateResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) +} + +// LabelsGetResponse contains the result of the LabelsGet request. +type LabelsGetResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// LabelsGet does *something* - TODO +func LabelsGet(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &LabelsGetResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } -// CreateLabel TODO -func CreateLabel(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// LabelsUpdateResponse contains the result of the LabelsUpdate request. +type LabelsUpdateResponse struct { + Success bool `json:"success"` + Message string `json:"message"` } -// Label TODO -func Label(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// LabelsUpdate does *something* - TODO +func LabelsUpdate(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &LabelsUpdateResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } -// UpdateLabel TODO -func UpdateLabel(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// LabelsDeleteResponse contains the result of the LabelsDelete request. +type LabelsDeleteResponse struct { + Success bool `json:"success"` + Message string `json:"message"` } -// Label TODO -func DeleteLabel(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// LabelsDelete does *something* - TODO +func LabelsDelete(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &LabelsDeleteResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } diff --git a/routes/me.go b/routes/me.go deleted file mode 100644 index b25ea6c..0000000 --- a/routes/me.go +++ /dev/null @@ -1,46 +0,0 @@ -package routes - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - - "github.com/lavab/api/db" - "github.com/lavab/api/dbutils" - "github.com/lavab/api/models" - "github.com/lavab/api/utils" -) - -// Me returns information about the current user (more exactly, a JSONized models.User) -func Me(w http.ResponseWriter, r *http.Request) { - session := models.CurrentSession(r) - user, ok := dbutils.GetUser(session.UserID) - if !ok { - debug := fmt.Sprintf("Session %s was deleted", session.ID) - if err := db.Delete("sessions", session.ID); err != nil { - debug = "Error when trying to delete session associated with inactive account" - log.Println("[routes.Me]", debug, err) - } - utils.ErrorResponse(w, 410, "Account deactivated", debug) - return - } - str, err := json.Marshal(user) - if err != nil { - debug := fmt.Sprint("Failed to marshal models.User:", user) - log.Println("[routes.Me]", debug) - utils.ErrorResponse(w, 500, "Internal server error", debug) - return - } - fmt.Fprint(w, string(str)) -} - -// UpdateMe TODO -func UpdateMe(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") -} - -// Sessions lists all active sessions for current user -func Sessions(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") -} diff --git a/routes/middleware.go b/routes/middleware.go new file mode 100644 index 0000000..ca06437 --- /dev/null +++ b/routes/middleware.go @@ -0,0 +1,69 @@ +package routes + +import ( + "net/http" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/zenazn/goji/web" + + "github.com/lavab/api/env" + "github.com/lavab/api/utils" +) + +type AuthMiddlewareResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +func AuthMiddleware(c *web.C, h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Read the Authorization header + header := r.Header.Get("Authorization") + if header == "" { + utils.JSONResponse(w, 401, &AuthMiddlewareResponse{ + Success: false, + Message: "Missing auth token", + }) + return + } + + // Split it into two parts + headerParts := strings.Split(header, " ") + if len(headerParts) != 2 || headerParts[0] != "Bearer" { + utils.JSONResponse(w, 401, &AuthMiddlewareResponse{ + Success: false, + Message: "Invalid authorization header", + }) + return + } + + // Get the token from the database + token, err := env.G.R.Tokens.GetToken(headerParts[1]) + if err != nil { + env.G.Log.WithFields(logrus.Fields{ + "error": err, + }).Error("Cannot retrieve session from the database") + + utils.JSONResponse(w, 401, &AuthMiddlewareResponse{ + Success: false, + Message: "Invalid authorization token", + }) + return + } + + // Check if it's expired + if token.Expired() { + utils.JSONResponse(w, 419, &AuthMiddlewareResponse{ + Success: false, + Message: "Authorization token has expired", + }) + env.G.R.Tokens.DeleteID(token.ID) + return + } + + // Continue to the next middleware/route + c.Env["session"] = token + h.ServeHTTP(w, r) + }) +} diff --git a/routes/sessions.go b/routes/sessions.go deleted file mode 100644 index 29b2566..0000000 --- a/routes/sessions.go +++ /dev/null @@ -1,91 +0,0 @@ -package routes - -import ( - "fmt" - "log" - "net/http" - - "github.com/gorilla/context" - "github.com/lavab/api/db" - "github.com/lavab/api/dbutils" - "github.com/lavab/api/models" - "github.com/lavab/api/models/base" - "github.com/lavab/api/utils" -) - -const SessionDurationInHours = 72 - -// Login gets a username and password and returns a session token on success -func Login(w http.ResponseWriter, r *http.Request) { - username, password := r.FormValue("username"), r.FormValue("password") - user, ok := dbutils.FindUserByName(username) - if !ok || user == nil || !utils.BcryptVerify(user.Password, password) { - utils.ErrorResponse(w, 403, "Wrong username or password", - fmt.Sprintf("user: %+v", user)) - return - } - - // TODO check number of sessions for the current user here - session := models.Session{ - Expiring: base.Expiring{utils.HoursFromNowString(SessionDurationInHours)}, - Resource: base.MakeResource(user.ID, ""), - } - session.Name = fmt.Sprintf("Auth session expiring on %s", session.ExpDate) - db.Insert("sessions", session) - - utils.JSONResponse(w, 200, map[string]interface{}{ - "message": "Authentication successful", - "success": true, - "session": session, - }) -} - -// Signup gets a username and password and creates a user account on success -func Signup(w http.ResponseWriter, r *http.Request) { - username, password := r.FormValue("username"), r.FormValue("password") - // regt := r.FormValue("reg_token") - - if _, ok := dbutils.FindUserByName(username); ok { - utils.ErrorResponse(w, 409, "Username already exists", "") - return - } - - hash, err := utils.BcryptHash(password) - if err != nil { - msg := "Bcrypt hashing has failed" - utils.ErrorResponse(w, 500, "Internal server error", msg) - log.Fatalln(msg) - } - - // TODO: sanitize user name (i.e. remove caps, periods) - - user := models.User{ - Resource: base.MakeResource(utils.UUID(), username), - Password: string(hash), - } - - if err := db.Insert("users", user); err != nil { - utils.ErrorResponse(w, 500, "Internal server error", - fmt.Sprintf("Couldn't insert %+v to database", user)) - } - - utils.JSONResponse(w, 201, map[string]interface{}{ - "message": "Signup successful", - "success": true, - "data": user, - }) -} - -// Logout destroys the current session token -func Logout(w http.ResponseWriter, r *http.Request) { - session := context.Get(r, "session").(*models.Session) - if err := db.Delete("sessions", session.ID); err != nil { - utils.ErrorResponse(w, 500, "Internal server error", - fmt.Sprint("Couldn't delete session %v. %v", session, err)) - } - utils.JSONResponse(w, 410, map[string]interface{}{ - "message": fmt.Sprintf("Successfully logged out", session.UserID), - "success": true, - "deleted": session.ID, - }) -} diff --git a/routes/threads.go b/routes/threads.go index ed4bd5a..74907cc 100644 --- a/routes/threads.go +++ b/routes/threads.go @@ -1,21 +1,49 @@ package routes import ( - "fmt" "net/http" + + "github.com/lavab/api/utils" ) -// Threads TODO -func Threads(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// ThreadsListResponse contains the result of the ThreadsList request. +type ThreadsListResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// ThreadsList shows all threads +func ThreadsList(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &ThreadsListResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) +} + +// ThreadsGetResponse contains the result of the ThreadsGet request. +type ThreadsGetResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// ThreadsGet returns information about a single thread. +func ThreadsGet(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &ThreadsGetResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } -// Thread TODO -func Thread(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// ThreadsUpdateResponse contains the result of the ThreadsUpdate request. +type ThreadsUpdateResponse struct { + Success bool `json:"success"` + Message string `json:"message"` } -// UpdateThread TODO -func UpdateThread(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"success\":false,\"message\":\"Sorry, not implemented yet\"}") +// ThreadsUpdate does *something* with a thread. +func ThreadsUpdate(w http.ResponseWriter, r *http.Request) { + utils.JSONResponse(w, 501, &ThreadsUpdateResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) } diff --git a/routes/tokens.go b/routes/tokens.go new file mode 100644 index 0000000..902eee9 --- /dev/null +++ b/routes/tokens.go @@ -0,0 +1,124 @@ +package routes + +import ( + "net/http" + "time" + + "github.com/Sirupsen/logrus" + "github.com/zenazn/goji/web" + + "github.com/lavab/api/env" + "github.com/lavab/api/models" + "github.com/lavab/api/utils" +) + +// TokensGetResponse contains the result of the TokensGet request. +type TokensGetResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Created *time.Time `json:"created,omitempty"` + Expires *time.Time `json:"expires,omitempty"` +} + +// TokensGet returns information about the current token. +func TokensGet(c *web.C, w http.ResponseWriter, r *http.Request) { + // Fetch the current session from the database + session := c.Env["session"].(*models.Token) + + // Respond with the token information + utils.JSONResponse(w, 200, &TokensGetResponse{ + Success: true, + Created: &session.DateCreated, + Expires: &session.ExpiryDate, + }) +} + +// TokensCreateRequest contains the input for the TokensCreate endpoint. +type TokensCreateRequest struct { + Username string `json:"username" schema:"username"` + Password string `json:"password" schema:"password"` +} + +// TokensCreateResponse contains the result of the TokensCreate request. +type TokensCreateResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Token *models.Token `json:"token,omitempty"` +} + +// TokensCreate allows logging in to an account. +func TokensCreate(w http.ResponseWriter, r *http.Request) { + // Decode the request + var input TokensCreateRequest + err := utils.ParseRequest(r, input) + if err != nil { + env.G.Log.WithFields(logrus.Fields{ + "error": err, + }).Warn("Unable to decode a request") + + utils.JSONResponse(w, 409, &TokensCreateResponse{ + Success: false, + Message: "Invalid input format", + }) + return + } + + // Authenticate the user + user, err := env.G.R.Accounts.FindAccountByName(input.Username) + if err != nil || !utils.BcryptVerify(user.Password, input.Password) { + utils.JSONResponse(w, 403, &TokensCreateResponse{ + Success: false, + Message: "Wrong username or password", + }) + return + } + + // Calculate the expiry date + expDate := time.Now().Add(time.Hour * time.Duration(env.G.Config.SessionDuration)) + + // Create a new token + token := &models.Token{ + Expiring: models.Expiring{expDate}, + Resource: models.MakeResource(user.ID, "Auth token expiring on "+expDate.Format(time.RFC3339)), + } + + // Insert int into the database + env.G.R.Tokens.Insert(token) + + // Respond with the freshly created token + utils.JSONResponse(w, 201, &TokensCreateResponse{ + Success: true, + Message: "Authentication successful", + Token: token, + }) +} + +// TokensDeleteResponse contains the result of the TokensDelete request. +type TokensDeleteResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// TokensDelete destroys the current session token. +func TokensDelete(c *web.C, w http.ResponseWriter, r *http.Request) { + // Get the session from the middleware + session := c.Env["session"].(*models.Token) + + // Delete it from the database + if err := env.G.R.Tokens.DeleteID(session.ID); err != nil { + env.G.Log.WithFields(logrus.Fields{ + "error": err, + }).Error("Unable to delete a session") + + utils.JSONResponse(w, 500, &TokensDeleteResponse{ + Success: true, + Message: "Internal server error - TO/DE/01", + }) + return + } + + utils.JSONResponse(w, 200, &TokensDeleteResponse{ + Success: true, + Message: "Successfully logged out", + }) +} diff --git a/utils/json.go b/utils/json.go deleted file mode 100644 index 52bdedd..0000000 --- a/utils/json.go +++ /dev/null @@ -1,28 +0,0 @@ -package utils - -import ( - "encoding/json" - "io" - "log" -) - -// MakeJSON is a wrapper over json.Marshal that returns an error message if an error occured -func MakeJSON(data map[string]interface{}) string { - res, err := json.Marshal(data) - if err != nil { - log.Fatalln("Error marshalling the response body.", err) - return "{\"status\":500,\"message\":\"Error occured while marshalling the response body\"}" - } - return string(res) -} - -// ReadJSON reads a JSON string as a generic map string -func ReadJSON(r io.Reader) (map[string]interface{}, error) { - decoder := json.NewDecoder(r) - out := map[string]interface{}{} - err := decoder.Decode(&out) - if err != nil { - return out, err - } - return out, nil -} diff --git a/utils/misc.go b/utils/misc.go index 1d0cace..704c5f3 100644 --- a/utils/misc.go +++ b/utils/misc.go @@ -1,23 +1,9 @@ package utils import ( - "crypto/rand" - "log" "os" - - "github.com/dchest/uniuri" ) -// RandomString returns a secure random string of a certain length -func RandomString(length int) string { - tmp := make([]byte, length) - _, err := rand.Read(tmp) - if err != nil { - log.Fatalln("Secure random string generation has failed.", err) - } - return string(tmp) -} - // FileExists is a stupid little wrapper of os.Stat that checks whether a file exists func FileExists(name string) bool { if _, err := os.Stat(name); os.IsNotExist(err) { @@ -25,8 +11,3 @@ func FileExists(name string) bool { } return true } - -// UUID returns a new Universally Unique IDentifier (UUID) -func UUID() string { - return uniuri.NewLen(uniuri.UUIDLen) -} diff --git a/utils/requests.go b/utils/requests.go new file mode 100644 index 0000000..83fe3d9 --- /dev/null +++ b/utils/requests.go @@ -0,0 +1,86 @@ +package utils + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/gorilla/schema" + + "github.com/lavab/api/env" +) + +var ( + // Error declarations + ErrInvalidContentType = errors.New("Invalid request content type") + + // gorilla/schema decoder is a shared object, as it caches information about structs + decoder = schema.NewDecoder() +) + +// JSONResponse writes JSON to an http.ResponseWriter with the corresponding status code +func JSONResponse(w http.ResponseWriter, status int, data interface{}) { + // Get rid of the invalid status codes + if status < 100 || status > 599 { + status = 200 + } + + // Try to marshal the input + result, err := json.Marshal(data) + if err != nil { + // Log the error + env.G.Log.WithFields(logrus.Fields{ + "error": err, + }).Error("Unable to marshal a message") + + // Set the result to the default value to prevent empty responses + result = []byte(`{"status":500,"message":"Error occured while marshalling the response body"}`) + } + + // Write the result + w.WriteHeader(status) + w.Write(result) +} + +func ParseRequest(r *http.Request, data interface{}) error { + // Get the contentType for comparsions + contentType := r.Header.Get("Content-Type") + + // Deterimine the passed ContentType + if strings.Contains(contentType, "application/json") { + // It's JSON, so read the body into a variable + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return err + } + + // And then unmarshal it into the passed interface + err = json.Unmarshal(body, data) + if err != nil { + return err + } + + return nil + } else if contentType == "" || + strings.Contains(contentType, "application/x-www-form-urlencoded") || + strings.Contains(contentType, "multipart/form-data") { + // net/http should be capable of parsing the form data + err := r.ParseForm() + if err != nil { + return err + } + + // Unmarshal them into the passed interface + err = decoder.Decode(data, r.PostForm) + if err != nil { + return err + } + + return nil + } + + return ErrInvalidContentType +} diff --git a/utils/responses.go b/utils/responses.go deleted file mode 100644 index 31c1c15..0000000 --- a/utils/responses.go +++ /dev/null @@ -1,34 +0,0 @@ -package utils - -import ( - "fmt" - "net/http" -) - -// JSONResponse writes JSON to an http.ResponseWriter with the corresponding status code -func JSONResponse(w http.ResponseWriter, status int, data map[string]interface{}) { - if status < 100 || status > 599 { - status = 200 - } - w.WriteHeader(status) - fmt.Fprint(w, MakeJSON(data)) -} - -// ErrorResponse TODO -func ErrorResponse(w http.ResponseWriter, code int, message string, debug string) { - out := map[string]interface{}{ - "debug": debug, - "message": message, - "status": code, - "success": false, - } - if debug == "" { - delete(out, "debug") - } - fmt.Fprint(w, MakeJSON(out)) -} - -// FormatNotRecognizedResponse TODO -func FormatNotRecognizedResponse(w http.ResponseWriter, err error) { - ErrorResponse(w, 400, "Format not recognized", err.Error()) -}