diff --git a/.travis.yml b/.travis.yml index 0c4e63c0..a5339475 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ go: - tip go_import_path: github.com/kataras/iris install: - - go get ./... # for iris-contrib/httpexpect and kataras/golog + - go get ./... # for iris-contrib/httpexpect, kataras/golog, boltdb/bolt(sessiondb, optional) script: - go test -v -cover ./... after_script: diff --git a/HISTORY.md b/HISTORY.md index 4ec7428c..ea0bc634 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -18,6 +18,72 @@ Developers are not forced to upgrade if they don't really need it. Upgrade whene **How to upgrade**: Open your command-line and execute this command: `go get -u github.com/kataras/iris`. +# Mo, 07 August 2017 | v8.2.0 + +No Common-API Changes. + +Good news for [iris sessions back-end databases](_examples/sessions) users. + +
+Info for session database authors +Session Database API Changed to: + +```go +type Database interface { + Load(sid string) RemoteStore + Sync(p SyncPayload) +} + +// SyncPayload reports the state of the session inside a database sync action. +type SyncPayload struct { + SessionID string + + Action Action + // on insert it contains the new key and the value + // on update it contains the existing key and the new value + // on delete it contains the key (the value is nil) + // on clear it contains nothing (empty key, value is nil) + // on destroy it contains nothing (empty key, value is nil) + Value memstore.Entry + // Store contains the whole memory store, this store + // contains the current, updated from memory calls, + // session data (keys and values). This way + // the database has access to the whole session's data + // every time. + Store RemoteStore +} + + +// RemoteStore is a helper which is a wrapper +// for the store, it can be used as the session "table" which will be +// saved to the session database. +type RemoteStore struct { + // Values contains the whole memory store, this store + // contains the current, updated from memory calls, + // session data (keys and values). This way + // the database has access to the whole session's data + // every time. + Values memstore.Store + // on insert it contains the expiration datetime + // on update it contains the new expiration datetime(if updated or the old one) + // on delete it will be zero + // on clear it will be zero + // on destroy it will be zero + Lifetime LifeTime +} +``` + +Read more at [sessions/database.go](sessions/database.go), view how three built'n session databases are being implemented [here](sessions/sessiondb). +
+ +All sessions databases are updated and they performant even faster than before. + +- **NEW** raw file-based session database implemented, example [here](_examples/sessions/database/file) +- **NEW** [boltdb-based](https://github.com/boltdb/bolt) session database implemented, example [here](_examples/sessions/database/boltdb) (recommended as it's safer and faster) +- [redis sessiondb](_examples/sessions/database/redis) updated to the latest api + +Under the cover, session database works entirely differently than before but nothing changed from the user's perspective, so upgrade with `go get -u github.com/kataras/iris` and sleep well. + # Tu, 01 August 2017 | v8.1.3 - Add `Option` function to the `html view engine`: https://github.com/kataras/iris/issues/694 diff --git a/README.md b/README.md index 2c68ccb9..90d35e06 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Iris is a fast, simple and efficient micro web framework for Go. It provides a b ### 📑 Table of contents * [Installation](#-installation) -* [Latest changes](https://github.com/kataras/iris/blob/master/HISTORY.md#tu-01-august-2017--v813) +* [Latest changes](https://github.com/kataras/iris/blob/master/HISTORY.md#mo-07-august-2017--v820) * [Learn](#-learn) * [HTTP Listening](_examples/#http-listening) * [Configuration](_examples/#configuration) @@ -318,7 +318,7 @@ Thank You for your trust! ### 📌 Version -Current: **8.1.3** +Current: **8.2.0** Each new release is pushed to the master. It stays there until the next version. When a next version is released then the previous version goes to its own branch with `gopkg.in` as its import path (and its own vendor folder), in order to keep it working "for-ever". diff --git a/_examples/README.md b/_examples/README.md index f6384c97..f588a328 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -194,9 +194,10 @@ iris session manager lives on its own [package](https://github.com/kataras/iris/ - [Standalone](sessions/standalone/main.go) - [Secure Cookie](sessions/securecookie/main.go) - [Flash Messages](sessions/flash-messages/main.go) -- [Database](sessions/database) - * [Redis](sessions/database/redis/main.go) +- [Databases](sessions/database) * [File](sessions/database/file/main.go) + * [BoltDB](sessions/database/boltdb/main.go) + * [Redis](sessions/database/redis/main.go) > You're free to use your own favourite sessions package if you'd like so. diff --git a/_examples/configuration/functional/main.go b/_examples/configuration/functional/main.go index 13dee581..8e5ab2b1 100644 --- a/_examples/configuration/functional/main.go +++ b/_examples/configuration/functional/main.go @@ -16,9 +16,9 @@ func main() { // Prefix: "With", code editors will help you navigate through all // configuration options without even a glitch to the documentation. - app.Run(iris.Addr(":8080"), iris.WithoutBanner, iris.WithCharset("UTF-8")) + app.Run(iris.Addr(":8080"), iris.WithoutStartupLog, iris.WithCharset("UTF-8")) // or before run: - // app.Configure(iris.WithoutBanner, iris.WithCharset("UTF-8")) + // app.Configure(iris.WithoutStartupLog, iris.WithCharset("UTF-8")) // app.Run(iris.Addr(":8080")) } diff --git a/_examples/http-listening/iris-configurator-and-host-configurator/counter/configurator.go b/_examples/http-listening/iris-configurator-and-host-configurator/counter/configurator.go index 0577e9ad..2e367859 100644 --- a/_examples/http-listening/iris-configurator-and-host-configurator/counter/configurator.go +++ b/_examples/http-listening/iris-configurator-and-host-configurator/counter/configurator.go @@ -26,7 +26,6 @@ func Configurator(app *iris.Application) { }() app.Get("/counter", func(ctx context.Context) { - ctx.Header("Content-Type", "text/plain") ctx.Writef("Counter value = %d", counterValue) }) } diff --git a/_examples/routing/reverse/main.go b/_examples/routing/reverse/main.go index 7cd888fc..4bc22eb2 100644 --- a/_examples/routing/reverse/main.go +++ b/_examples/routing/reverse/main.go @@ -17,6 +17,8 @@ func main() { ctx.Writef("The path after /anything is: %s", paramValue) }) + myroute.Name = "myroute" + // useful for links, although iris' view engine has the {{ urlpath "routename" "path values"}} already. app.Get("/reverse_myroute", func(ctx context.Context) { myrouteRequestPath := rv.Path(myroute.Name, "any/path") @@ -32,4 +34,5 @@ func main() { // http://localhost:8080/execute_myroute // http://localhost:8080/anything/any/path/here app.Run(iris.Addr(":8080")) -} + +} // See view/template_html_4 example for more. diff --git a/_examples/sessions/database/boltdb/main.go b/_examples/sessions/database/boltdb/main.go new file mode 100644 index 00000000..755eef95 --- /dev/null +++ b/_examples/sessions/database/boltdb/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "time" + + "github.com/kataras/iris" + "github.com/kataras/iris/context" + + "github.com/kataras/iris/sessions" + "github.com/kataras/iris/sessions/sessiondb/boltdb" +) + +func main() { + db, _ := boltdb.New("./sessions/sessions.db", 0666, "users") + + // close and unlock the database when control+C/cmd+C pressed + iris.RegisterOnInterrupt(func() { + db.Close() + }) + + sess := sessions.New(sessions.Config{ + Cookie: "sessionscookieid", + Expires: 1 * time.Minute, // <=0 means unlimited life + }) + + // + // IMPORTANT: + // + sess.UseDatabase(db) + + // the rest of the code stays the same. + app := iris.New() + + app.Get("/", func(ctx context.Context) { + ctx.Writef("You should navigate to the /set, /get, /delete, /clear,/destroy instead") + }) + app.Get("/set", func(ctx context.Context) { + s := sess.Start(ctx) + //set session values + s.Set("name", "iris") + + //test if setted here + ctx.Writef("All ok session setted to: %s", s.GetString("name")) + }) + + app.Get("/set/{key}/{value}", func(ctx context.Context) { + key, value := ctx.Params().Get("key"), ctx.Params().Get("value") + s := sess.Start(ctx) + // set session values + s.Set(key, value) + + // test if setted here + ctx.Writef("All ok session setted to: %s", s.GetString(key)) + }) + + app.Get("/get", func(ctx context.Context) { + // get a specific key, as string, if no found returns just an empty string + name := sess.Start(ctx).GetString("name") + + ctx.Writef("The name on the /set was: %s", name) + }) + + app.Get("/get/{key}", func(ctx context.Context) { + // get a specific key, as string, if no found returns just an empty string + name := sess.Start(ctx).GetString(ctx.Params().Get("key")) + + ctx.Writef("The name on the /set was: %s", name) + }) + + app.Get("/delete", func(ctx context.Context) { + // delete a specific key + sess.Start(ctx).Delete("name") + }) + + app.Get("/clear", func(ctx context.Context) { + // removes all entries + sess.Start(ctx).Clear() + }) + + app.Get("/destroy", func(ctx context.Context) { + //destroy, removes the entire session data and cookie + sess.Destroy(ctx) + }) + + app.Get("/update", func(ctx context.Context) { + // updates expire date with a new date + sess.ShiftExpiraton(ctx) + }) + + app.Run(iris.Addr(":8080")) +} diff --git a/_examples/sessions/database/file/main.go b/_examples/sessions/database/file/main.go index 06d28b57..40874627 100644 --- a/_examples/sessions/database/file/main.go +++ b/_examples/sessions/database/file/main.go @@ -1,6 +1,8 @@ package main import ( + "time" + "github.com/kataras/iris" "github.com/kataras/iris/context" @@ -9,9 +11,12 @@ import ( ) func main() { - db := file.New("./sessions/") + db, _ := file.New("./sessions/", 0666) - sess := sessions.New(sessions.Config{Cookie: "sessionscookieid"}) + sess := sessions.New(sessions.Config{ + Cookie: "sessionscookieid", + Expires: 45 * time.Minute, // <=0 means unlimited life + }) // // IMPORTANT: @@ -33,6 +38,16 @@ func main() { ctx.Writef("All ok session setted to: %s", s.GetString("name")) }) + app.Get("/set/{key}/{value}", func(ctx context.Context) { + key, value := ctx.Params().Get("key"), ctx.Params().Get("value") + s := sess.Start(ctx) + // set session values + s.Set(key, value) + + // test if setted here + ctx.Writef("All ok session setted to: %s", s.GetString(key)) + }) + app.Get("/get", func(ctx context.Context) { // get a specific key, as string, if no found returns just an empty string name := sess.Start(ctx).GetString("name") @@ -40,6 +55,13 @@ func main() { ctx.Writef("The name on the /set was: %s", name) }) + app.Get("/get/{key}", func(ctx context.Context) { + // get a specific key, as string, if no found returns just an empty string + name := sess.Start(ctx).GetString(ctx.Params().Get("key")) + + ctx.Writef("The name on the /set was: %s", name) + }) + app.Get("/delete", func(ctx context.Context) { // delete a specific key sess.Start(ctx).Delete("name") diff --git a/context/context.go b/context/context.go index 81061d6f..33d2956c 100644 --- a/context/context.go +++ b/context/context.go @@ -628,13 +628,13 @@ type Context interface { // BUT it isn't available by browsing, its handlers executed only when other handler's context call them // it can validate paths, has sessions, path parameters and all. // - // You can find the Route by app.Routes().Lookup("theRouteName") + // You can find the Route by app.GetRoute("theRouteName") // you can set a route name as: myRoute := app.Get("/mypath", handler)("theRouteName") // that will set a name to the route and returns its RouteInfo instance for further usage. // // It doesn't changes the global state, if a route was "offline" it remains offline. // - // app.None(...) and app.Routes().Offline(route)/.Online(route, method) + // app.None(...) and app.GetRoutes().Offline(route)/.Online(route, method) // // Example: https://github.com/kataras/iris/tree/master/_examples/routing/route-state // @@ -2296,13 +2296,13 @@ func (ctx *context) TransactionsSkipped() bool { // BUT it isn't available by browsing, its handlers executed only when other handler's context call them // it can validate paths, has sessions, path parameters and all. // -// You can find the Route by app.Routes().Lookup("theRouteName") +// You can find the Route by app.GetRoute("theRouteName") // you can set a route name as: myRoute := app.Get("/mypath", handler)("theRouteName") // that will set a name to the route and returns its RouteInfo instance for further usage. // // It doesn't changes the global state, if a route was "offline" it remains offline. // -// app.None(...) and app.Routes().Offline(route)/.Online(route, method) +// app.None(...) and app.GetRoutes().Offline(route)/.Online(route, method) // // Example: https://github.com/kataras/iris/tree/master/_examples/routing/route-state // diff --git a/core/memstore/gob.go b/core/memstore/gob.go new file mode 100644 index 00000000..aa53bc47 --- /dev/null +++ b/core/memstore/gob.go @@ -0,0 +1,68 @@ +package memstore + +import ( + "bytes" + "encoding/gob" + "io" + "time" +) + +// why? +// on the future we may change how these encoders/decoders +// and we may need different method for store and other for entry. + +func init() { + gob.Register(Store{}) + gob.Register(Entry{}) + gob.Register(time.Time{}) +} + +// GobEncode accepts a store and writes +// as series of bytes to the "w" writer. +func GobEncode(store Store, w io.Writer) error { + enc := gob.NewEncoder(w) + err := enc.Encode(store) + return err +} + +// GobSerialize same as GobEncode but it returns +// the bytes using a temp buffer. +func GobSerialize(store Store) ([]byte, error) { + w := new(bytes.Buffer) + err := GobEncode(store, w) + return w.Bytes(), err +} + +// GobEncodeEntry accepts an entry and writes +// as series of bytes to the "w" writer. +func GobEncodeEntry(entry Entry, w io.Writer) error { + enc := gob.NewEncoder(w) + err := enc.Encode(entry) + return err +} + +// GobSerializeEntry same as GobEncodeEntry but it returns +// the bytes using a temp buffer. +func GobSerializeEntry(entry Entry) ([]byte, error) { + w := new(bytes.Buffer) + err := GobEncodeEntry(entry, w) + return w.Bytes(), err +} + +// GobDecode accepts a series of bytes and returns +// the store. +func GobDecode(b []byte) (store Store, err error) { + dec := gob.NewDecoder(bytes.NewBuffer(b)) + // no reference because of: + // gob: decoding into local type *memstore.Store, received remote type Entry + err = dec.Decode(&store) + return +} + +// GobDecodeEntry accepts a series of bytes and returns +// the entry. +func GobDecodeEntry(b []byte) (entry Entry, err error) { + dec := gob.NewDecoder(bytes.NewBuffer(b)) + err = dec.Decode(&entry) + return +} diff --git a/core/memstore/memstore.go b/core/memstore/memstore.go index f5e7e271..8b092cc7 100644 --- a/core/memstore/memstore.go +++ b/core/memstore/memstore.go @@ -16,7 +16,7 @@ type ( // Entry is the entry of the context storage Store - .Values() Entry struct { Key string - value interface{} + ValueRaw interface{} immutable bool // if true then it can't change by its caller. } @@ -29,7 +29,7 @@ type ( func (e Entry) Value() interface{} { if e.immutable { // take its value, no pointer even if setted with a rreference. - vv := reflect.Indirect(reflect.ValueOf(e.value)) + vv := reflect.Indirect(reflect.ValueOf(e.ValueRaw)) // return copy of that slice if vv.Type().Kind() == reflect.Slice { @@ -48,18 +48,16 @@ func (e Entry) Value() interface{} { // if was *value it will return value{}. return vv.Interface() } - return e.value + return e.ValueRaw } -// the id is immutable(true or false)+key -// so the users will be able to use the same key -// to store two different entries (one immutable and other mutable). -// or no? better no, that will confuse and maybe result on unexpected results. -// I will just replace the value and the immutable bool value when Set if -// a key is already exists. -// func (e Entry) identifier() string {} - -func (r *Store) save(key string, value interface{}, immutable bool) { +// Save same as `Set` +// However, if "immutable" is true then saves it as immutable (same as `SetImmutable`). +// +// +// Returns the entry and true if it was just inserted, meaning that +// it will return the entry and a false boolean if the entry exists and it has been updated. +func (r *Store) Save(key string, value interface{}, immutable bool) (Entry, bool) { args := *r n := len(args) @@ -71,15 +69,15 @@ func (r *Store) save(key string, value interface{}, immutable bool) { // if called by `SetImmutable` // then allow the update, maybe it's a slice that user wants to update by SetImmutable method, // we should allow this - kv.value = value + kv.ValueRaw = value kv.immutable = immutable } else if kv.immutable == false { // if it was not immutable then user can alt it via `Set` and `SetImmutable` - kv.value = value + kv.ValueRaw = value kv.immutable = immutable } // else it was immutable and called by `Set` then disallow the update - return + return *kv, false } } @@ -89,25 +87,29 @@ func (r *Store) save(key string, value interface{}, immutable bool) { args = args[:n+1] kv := &args[n] kv.Key = key - kv.value = value + kv.ValueRaw = value kv.immutable = immutable *r = args - return + return *kv, true } // add kv := Entry{ Key: key, - value: value, + ValueRaw: value, immutable: immutable, } *r = append(args, kv) + return kv, true } // Set saves a value to the key-value storage. +// Returns the entry and true if it was just inserted, meaning that +// it will return the entry and a false boolean if the entry exists and it has been updated. +// // See `SetImmutable` and `Get`. -func (r *Store) Set(key string, value interface{}) { - r.save(key, value, false) +func (r *Store) Set(key string, value interface{}) (Entry, bool) { + return r.Save(key, value, false) } // SetImmutable saves a value to the key-value storage. @@ -116,10 +118,13 @@ func (r *Store) Set(key string, value interface{}) { // An Immutable entry should be only changed with a `SetImmutable`, simple `Set` will not work // if the entry was immutable, for your own safety. // +// Returns the entry and true if it was just inserted, meaning that +// it will return the entry and a false boolean if the entry exists and it has been updated. +// // Use it consistently, it's far slower than `Set`. // Read more about muttable and immutable go types: https://stackoverflow.com/a/8021081 -func (r *Store) SetImmutable(key string, value interface{}) { - r.save(key, value, true) +func (r *Store) SetImmutable(key string, value interface{}) (Entry, bool) { + return r.Save(key, value, true) } // Get returns the entry's value based on its key. @@ -205,3 +210,9 @@ func (r *Store) Len() int { args := *r return len(args) } + +// Serialize returns the byte representation of the current Store. +func (r Store) Serialize() []byte { // note: no pointer here, ignore linters if shows up. + b, _ := GobSerialize(r) + return b +} diff --git a/core/router/api_builder.go b/core/router/api_builder.go index d08e2025..e0b1fff4 100644 --- a/core/router/api_builder.go +++ b/core/router/api_builder.go @@ -487,6 +487,7 @@ func (rb *APIBuilder) StaticServe(systemPath string, requestPath ...string) *Rou } if err := ctx.ServeFile(spath, true); err != nil { + ctx.Application().Logger().Warnf("while trying to serve static file: '%v' on IP: '%s'", err, ctx.RemoteAddr()) ctx.StatusCode(http.StatusInternalServerError) } }) diff --git a/doc.go b/doc.go index dc64478b..f2966d44 100644 --- a/doc.go +++ b/doc.go @@ -35,7 +35,7 @@ Source code and other details for the project are available at GitHub: Current Version -8.1.3 +8.2.0 Installation diff --git a/iris.go b/iris.go index 5eabf1c6..1b3561b0 100644 --- a/iris.go +++ b/iris.go @@ -33,7 +33,7 @@ import ( const ( // Version is the current version number of the Iris Web Framework. - Version = "8.1.3" + Version = "8.2.0" ) // HTTP status codes as registered with IANA. diff --git a/sessions/database.go b/sessions/database.go index 90744ee9..02a8042e 100644 --- a/sessions/database.go +++ b/sessions/database.go @@ -1,24 +1,176 @@ package sessions -import "time" +import ( + "bytes" + "encoding/gob" + "io" + "sync" + + "github.com/kataras/iris/core/memstore" +) + +func init() { + gob.Register(RemoteStore{}) +} // Database is the interface which all session databases should implement -// By design it doesn't support any type of cookie session like other frameworks, -// I want to protect you, believe me, no context access (although we could) -// The scope of the database is to session somewhere the sessions in order to -// keep them after restarting the server, nothing more. -// the values are sessions by the underline session, the check for new sessions, or -// 'this session value should added' are made automatically -// you are able just to set the values to your backend database with Load function. -// session database doesn't have any write or read access to the session, the loading of -// the initial data is done by the Load(string) (map[string]interfface{}, *time.Time) function -// synchronization are made automatically, you can register more than one session database +// By design it doesn't support any type of cookie session like other frameworks. +// I want to protect you, believe me. +// The scope of the database is to store somewhere the sessions in order to +// keep them after restarting the server, nothing more. +// +// Synchronization are made automatically, you can register more than one session database // but the first non-empty Load return data will be used as the session values. -// The Expire Date is given with data to save because the session entry must keep trace -// of the expire date in the case of the server is restarted. So the server will recover -// expiration state of session entry and it will track the expiration again. -// If expireDate is nil, that's means that there is no expire date. +// +// +// Note: Expiration on Load is up to the database, meaning that: +// the database can decide how to retrieve and parse the expiration datetime +// +// I'll try to explain you the flow: +// +// .Start -> if session database attached then load from that storage and save to the memory, otherwise load from memory. The load from database is done once on the initialize of each session. +// .Get (important) -> load from memory, +// if database attached then it already loaded the values +// from database on the .Start action, so it will +// retrieve the data from the memory (fast) +// .Set -> set to the memory, if database attached then update the storage +// .Delete -> clear from memory, if database attached then update the storage +// .Destroy -> destroy from memory and client cookie, +// if database attached then update the storage with empty values, +// empty values means delete the storage with that specific session id. +// Using everything else except memory is slower than memory but database is +// fetched once at each session and its updated on every Set, Delete, +// Destroy at call-time. +// All other external sessions managers out there work different than Iris one as far as I know, +// you may find them more suited to your application, it depends. type Database interface { - Load(sid string) (datas map[string]interface{}, expireDate *time.Time) - Update(sid string, datas map[string]interface{}, expireDate *time.Time) + Load(sid string) RemoteStore + Sync(p SyncPayload) +} + +// New Idea, it should work faster for the most databases needs +// the only minus is that the databases is coupled with this package, they +// should import the kataras/iris/sessions package, but we don't use any +// database by-default so that's ok here. + +// Action reports the specific action that the memory store +// sends to the database. +type Action uint32 + +const ( + // ActionCreate occurs when add a key-value pair + // on the database session entry for the first time. + ActionCreate Action = iota + // ActionInsert occurs when add a key-value pair + // on the database session entry. + ActionInsert + // ActionUpdate occurs when modify an existing key-value pair + // on the database session entry. + ActionUpdate + // ActionDelete occurs when delete a specific value from + // a specific key from the database session entry. + ActionDelete + // ActionClear occurs when clear all values but keep the database session entry. + ActionClear + // ActionDestroy occurs when destroy, + // destroy is the action when clear all and remove the session entry from the database. + ActionDestroy +) + +// SyncPayload reports the state of the session inside a database sync action. +type SyncPayload struct { + SessionID string + + Action Action + // on insert it contains the new key and the value + // on update it contains the existing key and the new value + // on delete it contains the key (the value is nil) + // on clear it contains nothing (empty key, value is nil) + // on destroy it contains nothing (empty key, value is nil) + Value memstore.Entry + // Store contains the whole memory store, this store + // contains the current, updated from memory calls, + // session data (keys and values). This way + // the database has access to the whole session's data + // every time. + Store RemoteStore +} + +var spPool = sync.Pool{New: func() interface{} { return SyncPayload{} }} + +func acquireSyncPayload(session *Session, action Action) SyncPayload { + p := spPool.Get().(SyncPayload) + p.SessionID = session.sid + + // clone the life time, except the timer. + // lifetime := LifeTime{ + // Time: session.lifetime.Time, + // OriginalDuration: session.lifetime.OriginalDuration, + // } + + // lifetime := acquireLifetime(session.lifetime.OriginalDuration, nil) + + p.Store = RemoteStore{ + Values: session.values, + Lifetime: session.lifetime, + } + + p.Action = action + return p +} + +func releaseSyncPayload(p SyncPayload) { + p.Value.Key = "" + p.Value.ValueRaw = nil + + // releaseLifetime(p.Store.Lifetime) + spPool.Put(p) +} + +func syncDatabases(databases []Database, payload SyncPayload) { + for i, n := 0, len(databases); i < n; i++ { + databases[i].Sync(payload) + } + releaseSyncPayload(payload) +} + +// RemoteStore is a helper which is a wrapper +// for the store, it can be used as the session "table" which will be +// saved to the session database. +type RemoteStore struct { + // Values contains the whole memory store, this store + // contains the current, updated from memory calls, + // session data (keys and values). This way + // the database has access to the whole session's data + // every time. + Values memstore.Store + // on insert it contains the expiration datetime + // on update it contains the new expiration datetime(if updated or the old one) + // on delete it will be zero + // on clear it will be zero + // on destroy it will be zero + Lifetime LifeTime +} + +// Serialize returns the byte representation of this RemoteStore. +func (s RemoteStore) Serialize() ([]byte, error) { + w := new(bytes.Buffer) + err := encode(s, w) + return w.Bytes(), err +} + +// encode accepts a store and writes +// as series of bytes to the "w" writer. +func encode(s RemoteStore, w io.Writer) error { + enc := gob.NewEncoder(w) + err := enc.Encode(s) + return err +} + +// DecodeRemoteStore accepts a series of bytes and returns +// the store. +func DecodeRemoteStore(b []byte) (store RemoteStore, err error) { + dec := gob.NewDecoder(bytes.NewBuffer(b)) + err = dec.Decode(&store) + return } diff --git a/sessions/lifetime.go b/sessions/lifetime.go new file mode 100644 index 00000000..e87a47c2 --- /dev/null +++ b/sessions/lifetime.go @@ -0,0 +1,65 @@ +package sessions + +import ( + "time" +) + +// LifeTime controls the session expiration datetime. +type LifeTime struct { + // Remember, tip for the future: + // No need of gob.Register, because we embed the time.Time. + // And serious bug which has a result of me spending my whole evening: + // Because of gob encoding it doesn't encodes/decodes the other fields if time.Time is embedded + // (this should be a bug(go1.9-rc1) or not. We don't care atm) + time.Time + timer *time.Timer +} + +// Begin will begin the life based on the time.Now().Add(d). +// Use `Continue` to continue from a stored time(database-based session does that). +func (lt *LifeTime) Begin(d time.Duration, onExpire func()) { + if d <= 0 { + return + } + + lt.Time = time.Now().Add(d) + lt.timer = time.AfterFunc(d, onExpire) +} + +// Revive will continue the life based on the stored Time. +// Other words that could be used for this func are: Continue, Restore, Resc. +func (lt *LifeTime) Revive(onExpire func()) { + if lt.Time.IsZero() { + return + } + + now := time.Now() + if lt.Time.After(now) { + d := lt.Time.Sub(now) + lt.timer = time.AfterFunc(d, onExpire) + } +} + +// Shift resets the lifetime based on "d". +func (lt *LifeTime) Shift(d time.Duration) { + if d > 0 && lt.timer != nil { + lt.timer.Reset(d) + } +} + +// ExpireNow reduce the lifetime completely. +func (lt *LifeTime) ExpireNow() { + lt.Time = CookieExpireDelete + if lt.timer != nil { + lt.timer.Stop() + } +} + +// HasExpired reports whether "lt" represents is expired. +func (lt *LifeTime) HasExpired() bool { + if lt.IsZero() { + return false + } + + return lt.Time.Before(time.Now()) +} diff --git a/sessions/provider.go b/sessions/provider.go index 84d7c003..ba22073d 100644 --- a/sessions/provider.go +++ b/sessions/provider.go @@ -36,87 +36,102 @@ func (p *provider) RegisterDatabase(db Database) { p.mu.Unlock() } -// startAutoDestroy start a task which destoy the session when expire date is reached, -// but only if `expires` parameter is positive. It updates the expire date of the session from `expires` parameter. -func (p *provider) startAutoDestroy(s *Session, expires time.Duration) bool { - res := expires > 0 - if res { // if not unlimited life duration and no -1 (cookie remove action is based on browser's session) - expireDate := time.Now().Add(expires) - - s.expireAt = &expireDate - s.timer = time.AfterFunc(expires, func() { - // the destroy makes the check if this session is exists then or not, - // this is used to destroy the session from the server-side also - // it's good to have here for security reasons, I didn't add it on the gc function to separate its action - p.Destroy(s.sid) - }) - } - - return res -} - // newSession returns a new session from sessionid func (p *provider) newSession(sid string, expires time.Duration) *Session { - values, expireAt := p.loadSessionValuesFromDB(sid) + onExpire := func() { + p.Destroy(sid) + } + + values, lifetime := p.loadSessionFromDB(sid) + // simple and straight: + if !lifetime.IsZero() { + // if stored time is not zero + // start a timer based on the stored time, if not expired. + lifetime.Revive(onExpire) + } else { + // Remember: if db not exist or it has been expired + // then the stored time will be zero(see loadSessionFromDB) and the values will be empty. + // + // Even if the database has an unlimited session (possible by a previous app run) + // priority to the "expires" is given, + // again if <=0 then it does nothing. + lifetime.Begin(expires, onExpire) + } + + // I ended up without the need of a duration field on lifetime, + // but these are some of my previous comments, they will be required for any future change. + // + // OK I think go has a bug when gob on embedded time.Time + // even if we `gob.Register(LifeTime)` + // the OriginalDuration is not saved to the gob file and it cannot be retrieved, it's always 0. + // But if we do not embed the `time.Time` inside the `LifeTime` then + // it's working. + // i.e type LifeTime struct { time.Time; OriginalDuration time.Duration} -> this doesn't + // type LifeTime struct {Time time.Time; OriginalDuration time.Duration} -> this works + // So we have two options: + // 1. don't embed the time.Time -> we will have to use lifetime.Time to get its functions, which doesn't seems right to me + // 2. embed the time.Time and compare their times with `lifetime.After(time.Now().Add(expires))`, it seems right but it + // should be slower. + // + // I'll use the 1. and put some common time.Time functions, like After, IsZero on the `LifeTime` type too. + // + // if db exists but its lifetime is bigger than the expires (very raire, + // the source code should be compatible with the databases, + // should we print a warning to the user? it is his/her fault + // use the database's lifetime or the configurated? + // if values.Len() > 0 && lifetime.OriginalDuration != expires { + // golog.Warnf(`session database: stored expire time(dur=%d) is differnet than the configuration(dur=%d) + // application will use the configurated one`, lifetime.OriginalDuration, expires) + // lifetime.Reset(expires) + // } sess := &Session{ sid: sid, provider: p, values: values, flashes: make(map[string]*flashMessage), - expireAt: expireAt, + lifetime: lifetime, } - if (len(values) > 0) && (sess.expireAt != nil) { - // Restore expiration state - // However, if session save in database has no expiration date, - // therefore the expiration will be reinitialised with session configuration - expires = sess.expireAt.Sub(time.Now()) - } - - p.startAutoDestroy(sess, expires) - return sess } -// can return nil memstore -func (p *provider) loadSessionValuesFromDB(sid string) (memstore.Store, *time.Time) { +func (p *provider) loadSessionFromDB(sid string) (memstore.Store, LifeTime) { var store memstore.Store - var expireDate *time.Time + var lifetime LifeTime + firstValidIdx := 1 for i, n := 0, len(p.databases); i < n; i++ { - dbValues, currentExpireDate := p.databases[i].Load(sid) - if dbValues != nil && len(dbValues) > 0 { - for k, v := range dbValues { - store.Set(k, v) - } + storeDB := p.databases[i].Load(sid) + if storeDB.Lifetime.HasExpired() { // if expired then skip this db + firstValidIdx++ + continue } - if (currentExpireDate != nil) && ((expireDate == nil) || expireDate.After(*currentExpireDate)) { - expireDate = currentExpireDate + if lifetime.IsZero() { + // update the lifetime to the most valid + lifetime = storeDB.Lifetime + } + + if n == firstValidIdx { + // if one database then set the store as it is + store = storeDB.Values + } else { + // else append this database's key-value pairs + // to the store + storeDB.Values.Visit(func(key string, value interface{}) { + store.Set(key, value) + }) } } - // Check if session has already expired - if (expireDate != nil) && expireDate.Before(time.Now()) { - return nil, nil - } + // Note: if one database and it's being expired then the lifetime will be zero(unlimited) + // this by itself is wrong but on the `newSession` we make check of this case too and update the lifetime + // if the configuration has expiration registered. - return store, expireDate -} - -func (p *provider) updateDatabases(sess *Session, store memstore.Store) { - if l := store.Len(); l > 0 { - mapValues := make(map[string]interface{}, l) - - store.Visit(func(k string, v interface{}) { - mapValues[k] = v - }) - - for i, n := 0, len(p.databases); i < n; i++ { - p.databases[i].Update(sess.sid, mapValues, sess.expireAt) - } - } + /// TODO: bug on destroy doesn't being remove the file + // we will have to see it, it's not db's problem it's here on provider destroy or lifetime onExpire. + return store, lifetime } // Init creates the session and returns it @@ -128,8 +143,10 @@ func (p *provider) Init(sid string, expires time.Duration) *Session { return newSession } -// UpdateExpiraton update expire date of a session, plus it updates destroy task -func (p *provider) UpdateExpiraton(sid string, expires time.Duration) (done bool) { +// UpdateExpiraton update expire date of a session. +// if expires > 0 then it updates the destroy task. +// if expires <=0 then it does nothing, to destroy a session call the `Destroy` func instead. +func (p *provider) UpdateExpiraton(sid string, expires time.Duration) bool { if expires <= 0 { return false } @@ -137,26 +154,11 @@ func (p *provider) UpdateExpiraton(sid string, expires time.Duration) (done bool p.mu.Lock() sess, found := p.sessions[sid] p.mu.Unlock() - if !found { return false } - if sess.timer == nil { - return p.startAutoDestroy(sess, expires) - } else { - if expires <= 0 { - sess.timer.Stop() - sess.timer = nil - sess.expireAt = nil - } else { - expireDate := time.Now().Add(expires) - - sess.expireAt = &expireDate - sess.timer.Reset(expires) - } - } - + sess.lifetime.Shift(expires) return true } @@ -180,17 +182,9 @@ func (p *provider) Read(sid string, expires time.Duration) *Session { func (p *provider) Destroy(sid string) { p.mu.Lock() if sess, found := p.sessions[sid]; found { - sess.values = nil - sess.flashes = nil - if sess.timer != nil { - sess.timer.Stop() - } - - delete(p.sessions, sid) - p.updateDatabases(sess, nil) + p.deleteSession(sess) } p.mu.Unlock() - } // DestroyAll removes all sessions @@ -199,13 +193,12 @@ func (p *provider) Destroy(sid string) { func (p *provider) DestroyAll() { p.mu.Lock() for _, sess := range p.sessions { - if sess.timer != nil { - sess.timer.Stop() - } - - delete(p.sessions, sess.ID()) - p.updateDatabases(sess, nil) + p.deleteSession(sess) } p.mu.Unlock() - +} + +func (p *provider) deleteSession(sess *Session) { + delete(p.sessions, sess.sid) + syncDatabases(p.databases, acquireSyncPayload(sess, ActionDestroy)) } diff --git a/sessions/session.go b/sessions/session.go index 942001dc..54bb1d1a 100644 --- a/sessions/session.go +++ b/sessions/session.go @@ -3,7 +3,6 @@ package sessions import ( "strconv" "sync" - "time" "github.com/kataras/iris/core/errors" "github.com/kataras/iris/core/memstore" @@ -27,8 +26,7 @@ type ( // NOTE: flashes are not managed by third-party, only inside session struct. flashes map[string]*flashMessage mu sync.RWMutex - expireAt *time.Time // nil pointer means no expire date - timer *time.Timer + lifetime LifeTime provider *provider } @@ -49,22 +47,6 @@ func (s *Session) IsNew() bool { return s.isNew } -// HasExpireDate test if this session has an expire date, if not, this session never expires -func (s *Session) HasExpireDate() bool { - return s.expireAt != nil -} - -// GetExpireDate get the expire date, if this session has no expire date, the returned value has the zero value -func (s *Session) GetExpireDate() time.Time { - var res time.Time - - if s.expireAt != nil { - res = *s.expireAt - } - - return res -} - // Get returns a value based on its "key". func (s *Session) Get(key string) interface{} { s.mu.RLock() @@ -281,16 +263,34 @@ func (s *Session) VisitAll(cb func(k string, v interface{})) { } func (s *Session) set(key string, value interface{}, immutable bool) { + action := ActionCreate // defaults to create, means the first insert. + s.mu.Lock() - if immutable { - s.values.SetImmutable(key, value) - } else { - s.values.Set(key, value) - } + isFirst := s.values.Len() == 0 + entry, isNew := s.values.Save(key, value, immutable) + s.isNew = false + s.mu.Unlock() - s.updateDatabases() - s.isNew = false + if !isFirst { + // we could use s.isNew + // which is setted at sessions.go#Start when values are empty + // but here we want the specific key-value pair's state. + if isNew { + action = ActionInsert + } else { + action = ActionUpdate + } + } + + /// TODO: remove the expireAt pointer, wtf, we could use zero time instead, + // that was not my commit so I will ask for permission first... + // rename the expireAt to expiresAt, it seems to make more sense to me + + p := acquireSyncPayload(s, action) + p.Value = entry + + syncDatabases(s.provider.databases, p) } // Set fills the session with an entry"value", based on its "key". @@ -339,20 +339,18 @@ func (s *Session) SetFlash(key string, value interface{}) { func (s *Session) Delete(key string) bool { s.mu.Lock() removed := s.values.Remove(key) - s.mu.Unlock() - - s.updateDatabases() if removed { s.isNew = false } + s.mu.Unlock() + + p := acquireSyncPayload(s, ActionDelete) + p.Value = memstore.Entry{Key: key} + syncDatabases(s.provider.databases, p) return removed } -func (s *Session) updateDatabases() { - s.provider.updateDatabases(s, s.values) -} - // DeleteFlash removes a flash message by its key. func (s *Session) DeleteFlash(key string) { s.mu.Lock() @@ -364,10 +362,11 @@ func (s *Session) DeleteFlash(key string) { func (s *Session) Clear() { s.mu.Lock() s.values.Reset() + s.isNew = false s.mu.Unlock() - s.updateDatabases() - s.isNew = false + p := acquireSyncPayload(s, ActionClear) + syncDatabases(s.provider.databases, p) } // ClearFlashes removes all flash messages. diff --git a/sessions/sessiondb/boltdb/database.go b/sessions/sessiondb/boltdb/database.go new file mode 100644 index 00000000..4b2292da --- /dev/null +++ b/sessions/sessiondb/boltdb/database.go @@ -0,0 +1,223 @@ +package boltdb + +import ( + "bytes" + "os" + "path/filepath" + "time" + + "github.com/boltdb/bolt" + "github.com/kataras/golog" + "github.com/kataras/iris/core/errors" + "github.com/kataras/iris/sessions" +) + +// DefaultFileMode used as the default database's "fileMode" +// for creating the sessions directory path, opening and write +// the session boltdb(file-based) storage. +var ( + DefaultFileMode = 0666 +) + +// Database the BoltDB(file-based) session storage. +type Database struct { + path string // path included the name, i.e sessions/store.db + fileMode os.FileMode // defaults to 0666. + table []byte + Service *bolt.DB // `New` sets it but it can be override exactly after `New`, use with caution. + async bool +} + +var ( + // ErrOptionsMissing returned on `New` when path or tableName are empty. + ErrOptionsMissing = errors.New("required options are missing") +) + +// New creates and returns a new BoltDB(file-based) storage +// instance based on the "path". +// Path should include the filename and the directory(aka fullpath), i.e sessions/store.db. +// +// It will remove any old session files. +func New(path string, fileMode os.FileMode, bucketName string) (*Database, error) { + + if path == "" || bucketName == "" { + return nil, ErrOptionsMissing + } + + if fileMode <= 0 { + fileMode = os.FileMode(DefaultFileMode) + } + + // create directories if necessary + if err := os.MkdirAll(filepath.Dir(path), fileMode); err != nil { + golog.Errorf("error while trying to create the necessary directories for %s: %v", path, err) + return nil, err + } + + service, err := bolt.Open(path, 0600, + &bolt.Options{Timeout: 15 * time.Second}, + ) + + bucket := []byte(bucketName) + + if err != nil { + golog.Errorf("unable to initialize the BoltDB-based session database: %v", err) + return nil, err + } + + service.Update(func(tx *bolt.Tx) (err error) { + _, err = tx.CreateBucketIfNotExists(bucket) + return + }) + + db := &Database{path: path, fileMode: fileMode, + table: bucket, Service: service, + } + + return db, db.Cleanup() +} + +// Cleanup removes any invalid(have expired) session entries, +// it's being called automatically on `New` as well. +func (db *Database) Cleanup() error { + err := db.Service.Update(func(tx *bolt.Tx) error { + b := db.getBucket(tx) + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + if len(k) == 0 { // empty key, continue to the next pair + continue + } + + storeDB, err := sessions.DecodeRemoteStore(v) + if err != nil { + continue + } + + if storeDB.Lifetime.HasExpired() { + if err := c.Delete(); err != nil { + golog.Warnf("troubles when cleanup a session remote store from BoltDB: %v", err) + } + } + } + + return nil + }) + + return err +} + +// Async if true passed then it will use different +// go routines to update the BoltDB(file-based) storage. +func (db *Database) Async(useGoRoutines bool) *Database { + db.async = useGoRoutines + return db +} + +// Load loads the sessions from the BoltDB(file-based) session storage. +func (db *Database) Load(sid string) (storeDB sessions.RemoteStore) { + bsid := []byte(sid) + err := db.Service.View(func(tx *bolt.Tx) (err error) { + // db.getSessBucket(tx, sid) + b := db.getBucket(tx) + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + if len(k) == 0 { // empty key, continue to the next pair + continue + } + + if bytes.Equal(k, bsid) { // session id should be the name of the key-value pair + storeDB, err = sessions.DecodeRemoteStore(v) // decode the whole value, as a remote store + break + } + } + return + }) + + if err != nil { + golog.Errorf("error while trying to load from the remote store: %v", err) + } + + return +} + +// Sync syncs the database with the session's (memory) store. +func (db *Database) Sync(p sessions.SyncPayload) { + if db.async { + go db.sync(p) + } else { + db.sync(p) + } +} + +func (db *Database) sync(p sessions.SyncPayload) { + bsid := []byte(p.SessionID) + + if p.Action == sessions.ActionDestroy { + if err := db.destroy(bsid); err != nil { + golog.Errorf("error while destroying a session(%s) from boltdb: %v", + p.SessionID, err) + } + return + } + + s, err := p.Store.Serialize() + if err != nil { + golog.Errorf("error while serializing the remote store: %v", err) + } + + err = db.Service.Update(func(tx *bolt.Tx) error { + return db.getBucket(tx).Put(bsid, s) + }) + if err != nil { + golog.Errorf("error while writing the session bucket: %v", err) + } +} + +func (db *Database) destroy(bsid []byte) error { + return db.Service.Update(func(tx *bolt.Tx) error { + return db.getBucket(tx).Delete(bsid) + }) +} + +// we store the whole data to the key-value pair of the root bucket +// so we don't need a separate bucket for each session +// this method could be faster if we had large data to store +// but with sessions we recommend small ammount of data, so the method finally choosen +// is faster (decode/encode the whole store + lifetime and return it as it's) +// +// func (db *Database) getSessBucket(tx *bolt.Tx, sid string) (*bolt.Bucket, error) { +// table, err := db.getBucket(tx).CreateBucketIfNotExists([]byte(sid)) +// return table, err +// } + +func (db *Database) getBucket(tx *bolt.Tx) *bolt.Bucket { + return tx.Bucket(db.table) +} + +// Len reports the number of sessions that are stored to the this BoltDB table. +func (db *Database) Len() (num int) { + db.Service.View(func(tx *bolt.Tx) error { + // Assume bucket exists and has keys + b := db.getBucket(tx) + if b == nil { + return nil + } + + b.ForEach(func([]byte, []byte) error { + num++ + return nil + }) + return nil + }) + return +} + +// Close shutdowns the BoltDB connection. +func (db *Database) Close() error { + err := db.Service.Close() + if err != nil { + golog.Warnf("closing the BoltDB connection: %v", err) + } + + return err +} diff --git a/sessions/sessiondb/file/database.go b/sessions/sessiondb/file/database.go index f6edcfb0..3e9ad5fa 100644 --- a/sessions/sessiondb/file/database.go +++ b/sessions/sessiondb/file/database.go @@ -1,128 +1,214 @@ package file import ( - "bytes" - "encoding/gob" "io/ioutil" "os" "path/filepath" - "time" "github.com/kataras/golog" + "github.com/kataras/iris/core/errors" + "github.com/kataras/iris/sessions" ) +// DefaultFileMode used as the default database's "fileMode" +// for creating the sessions directory path, opening and write the session file. var ( - // PathFileMode for creating the sessions directory path, opening and write the session file. - // Defaults to 0666. - PathFileMode uint32 = 0666 + DefaultFileMode = 0666 ) // Database is the basic file-storage session database. +// +// What it does +// It removes old(expired) session files, at init (`Cleanup`). +// It creates a session file on the first inserted key-value session data. +// It removes a session file on destroy. +// It sync the session file to the session's memstore on any other action (insert, delete, clear). +// It automatically remove the session files on runtime when a session is expired. +// +// Remember: sessions are not a storage for large data, everywhere: on any platform on any programming language. type Database struct { - path string + dir string + fileMode os.FileMode // defaults to 0666 if missing. + // if true then it will use go routines to: + // append or re-write a file + // create a file + // remove a file + async bool } -// New returns a new file-storage database instance based on the "path". -func New(path string) *Database { - lindex := path[len(path)-1] +// New creates and returns a new file-storage database instance based on the "path". +// It will remove any old session files. +func New(directoryPath string, fileMode os.FileMode) (*Database, error) { + lindex := directoryPath[len(directoryPath)-1] if lindex != os.PathSeparator && lindex != '/' { - path += string(os.PathSeparator) + directoryPath += string(os.PathSeparator) } + + if fileMode <= 0 { + fileMode = os.FileMode(DefaultFileMode) + } + // create directories if necessary - os.MkdirAll(path, os.FileMode(PathFileMode)) - return &Database{path: path} + if err := os.MkdirAll(directoryPath, fileMode); err != nil { + return nil, err + } + + db := &Database{dir: directoryPath, fileMode: fileMode} + return db, db.Cleanup() } -func (d *Database) sessPath(sid string) string { - return filepath.Join(d.path, sid) +// Cleanup removes any invalid(have expired) session files, it's being called automatically on `New` as well. +func (db *Database) Cleanup() error { + return filepath.Walk(db.dir, func(path string, info os.FileInfo, err error) error { + if info.IsDir() { + return nil + } + sessPath := path + storeDB, _ := db.load(sessPath) // we don't care about errors here, the file may be not a session a file at all. + if storeDB.Lifetime.HasExpired() { + os.Remove(path) + } + return nil + }) } -// Load loads the values to the underline -func (d *Database) Load(sid string) (values map[string]interface{}, expireDate *time.Time) { - sessPath := d.sessPath(sid) - f, err := os.OpenFile(sessPath, os.O_RDONLY, os.FileMode(PathFileMode)) +// FileMode for creating the sessions directory path, opening and write the session file. +// +// Defaults to 0666. +func (db *Database) FileMode(fileMode uint32) *Database { + db.fileMode = os.FileMode(fileMode) + return db +} + +// Async if true passed then it will use go routines to: +// append or re-write a file +// create a file +// remove a file. +// +// Defaults to false. +func (db *Database) Async(useGoRoutines bool) *Database { + db.async = useGoRoutines + return db +} + +func (db *Database) sessPath(sid string) string { + return filepath.Join(db.dir, sid) +} + +// Load loads the values from the storage and returns them +func (db *Database) Load(sid string) sessions.RemoteStore { + sessPath := db.sessPath(sid) + store, err := db.load(sessPath) + if err != nil { + golog.Error(err.Error()) + } + return store +} + +func (db *Database) load(fileName string) (storeDB sessions.RemoteStore, loadErr error) { + f, err := os.OpenFile(fileName, os.O_RDONLY, db.fileMode) if err != nil { - // we don't care if filepath doesn't exists yet, it will be created on Update. + // we don't care if filepath doesn't exists yet, it will be created later on. return } defer f.Close() - val, err := ioutil.ReadAll(f) + contents, err := ioutil.ReadAll(f) if err != nil { - // we don't care if filepath doesn't exists yet, it will be created on Update. - golog.Errorf("error while reading the session file's data: %v", err) + loadErr = errors.New("error while reading the session file's data: %v").Format(err) return } - if err == nil { - err = DeserializeBytes(val, &values) - if err != nil { // we care for this error only - golog.Errorf("load error: %v", err) - } - } + storeDB, err = sessions.DecodeRemoteStore(contents) - return // no expiration -} - -// serialize the values to be stored as strings inside the session file-storage. -func serialize(values map[string]interface{}) []byte { - val, err := SerializeBytes(values) - if err != nil { - golog.Errorf("serialize error: %v", err) - } - - return val -} - -func (d *Database) expireSess(sid string) { - go os.Remove(d.sessPath(sid)) -} - -// Update updates the session file-storage. -func (d *Database) Update(sid string, newValues map[string]interface{}, expireDate *time.Time) { - - if len(newValues) == 0 { // means delete by call - d.expireSess(sid) + if err != nil { // we care for this error only + loadErr = errors.New("load error: %v").Format(err) return } - // delete the file on expiration - if expireDate != nil && !expireDate.IsZero() { - now := time.Now() + return +} - if expireDate.Before(now) { - // already expirated, delete it now and return. - d.expireSess(sid) - return +// Sync syncs the database. +func (db *Database) Sync(p sessions.SyncPayload) { + if db.async { + go db.sync(p) + } else { + db.sync(p) + } +} + +func (db *Database) sync(p sessions.SyncPayload) { + + // if destroy then remove the file from the disk + if p.Action == sessions.ActionDestroy { + if err := db.destroy(p.SessionID); err != nil { + golog.Errorf("error while destroying and removing the session file: %v", err) } - // otherwise set a timer to delete the file automatically - afterDur := expireDate.Sub(now) - time.AfterFunc(afterDur, func() { - d.expireSess(sid) - }) + return } - if err := ioutil.WriteFile(d.sessPath(sid), serialize(newValues), os.FileMode(PathFileMode)); err != nil { - golog.Errorf("error while writing the session to the file: %v", err) + if err := db.override(p.SessionID, p.Store); err != nil { + golog.Errorf("error while writing the session file: %v", err) } + } -// SerializeBytes serializes the "m" into bytes using gob encoder and returns the result. -func SerializeBytes(m interface{}) ([]byte, error) { - buf := new(bytes.Buffer) - enc := gob.NewEncoder(buf) - err := enc.Encode(m) - if err == nil { - return buf.Bytes(), nil +// good idea but doesn't work, it is not just an array of entries +// which can be appended with the gob...anyway session data should be small so we don't have problem +// with that: + +// on insert new data, it appends to the file +// func (db *Database) insert(sid string, entry memstore.Entry) error { +// f, err := os.OpenFile( +// db.sessPath(sid), +// os.O_WRONLY|os.O_CREATE|os.O_RDWR|os.O_APPEND, +// db.fileMode, +// ) + +// if err != nil { +// return err +// } + +// if _, err := f.Write(serializeEntry(entry)); err != nil { +// f.Close() +// return err +// } + +// return f.Close() +// } + +// removes all entries but keeps the file. +// func (db *Database) clearAll(sid string) error { +// return ioutil.WriteFile( +// db.sessPath(sid), +// []byte{}, +// db.fileMode, +// ) +// } + +// on update, remove and clear, it re-writes the file to the current values(may empty). +func (db *Database) override(sid string, store sessions.RemoteStore) error { + s, err := store.Serialize() + if err != nil { + return err } - return nil, err + return ioutil.WriteFile( + db.sessPath(sid), + s, + db.fileMode, + ) } -// DeserializeBytes converts the bytes to a go value and puts that to "m" using the gob decoder. -func DeserializeBytes(b []byte, m interface{}) error { - dec := gob.NewDecoder(bytes.NewBuffer(b)) - return dec.Decode(m) //no reference here otherwise doesn't work because of go remote object +// on destroy, it removes the file +func (db *Database) destroy(sid string) error { + return db.expireSess(sid) +} + +func (db *Database) expireSess(sid string) error { + sessPath := db.sessPath(sid) + return os.Remove(sessPath) } diff --git a/sessions/sessiondb/redis/database.go b/sessions/sessiondb/redis/database.go index df190f8d..7397523d 100644 --- a/sessions/sessiondb/redis/database.go +++ b/sessions/sessiondb/redis/database.go @@ -1,103 +1,87 @@ package redis import ( - "bytes" - "encoding/gob" - "time" - "github.com/kataras/golog" + "github.com/kataras/iris/sessions" "github.com/kataras/iris/sessions/sessiondb/redis/service" ) // Database the redis back-end session database for the sessions. type Database struct { redis *service.Service + async bool } // New returns a new redis database. func New(cfg ...service.Config) *Database { return &Database{redis: service.New(cfg...)} + // Note: no need to clean up here, the redis should handle these automatically because of the "SETEX" + // but that expiration doesn't depend on the session, instead it depends on the `MaxAgeSeconds` + // of the redis database configuration. } // Config returns the configuration for the redis server bridge, you can change them. -func (d *Database) Config() *service.Config { - return d.redis.Config +func (db *Database) Config() *service.Config { + return db.redis.Config +} + +// Async if true passed then it will use different +// go routines to update the redis storage. +func (db *Database) Async(useGoRoutines bool) *Database { + db.async = useGoRoutines + return db } // Load loads the values to the underline. -func (d *Database) Load(sid string) (datas map[string]interface{}, expireDate *time.Time) { - values := make(map[string]interface{}) +func (db *Database) Load(sid string) (storeDB sessions.RemoteStore) { + // values := make(map[string]interface{}) - if !d.redis.Connected { //yes, check every first time's session for valid redis connection - d.redis.Connect() - _, err := d.redis.PingPong() + if !db.redis.Connected { //yes, check every first time's session for valid redis connection + db.redis.Connect() + _, err := db.redis.PingPong() if err != nil { golog.Errorf("redis database error on connect: %v", err) return } } - //fetch the values from this session id and copy-> store them - val, err := d.redis.GetBytes(sid) - if err == nil { - // err removed because of previous TODO - DeserializeBytes(val, &values) - } - - datas, _ = values["session-data"].(map[string]interface{}) - - dbExpireDateValue, exists := values["expire-date"] - if !exists { - return - } - - expireDateValue, ok := dbExpireDateValue.(time.Time) - if !ok { - return - } - - return datas, &expireDateValue -} - -// serialize the values to be stored as strings inside the Redis, we panic at any serialization error here -func serialize(values map[string]interface{}) []byte { - val, err := SerializeBytes(values) + // fetch the values from this session id and copy-> store them + storeMaybe, err := db.redis.Get(sid) if err != nil { - return nil + golog.Errorf("error while trying to load session values(%s) from redis: %v", sid, err) + return } - return val + storeDB, ok := storeMaybe.(sessions.RemoteStore) + if !ok { + golog.Errorf(`error while trying to load session values(%s) from redis: + the retrieved value is not a sessions.RemoteStore type, please report that as bug, it should never occur`, + sid) + return + } + + return } -// Update updates the real redis store -func (d *Database) Update(sid string, newValues map[string]interface{}, expireDate *time.Time) { - if len(newValues) == 0 { - go d.redis.Delete(sid) +// Sync syncs the database. +func (db *Database) Sync(p sessions.SyncPayload) { + if db.async { + go db.sync(p) } else { - datas := map[string]interface{}{"session-data": newValues} - if expireDate != nil { - datas["expire-date"] = *expireDate - } + db.sync(p) + } +} - //set/update all the values - go d.redis.Set(sid, serialize(datas)) +func (db *Database) sync(p sessions.SyncPayload) { + if p.Action == sessions.ActionDestroy { + db.redis.Delete(p.SessionID) + return + } + storeB, err := p.Store.Serialize() + if err != nil { + golog.Error("error while encoding the remote session store") + return } -} - -// SerializeBytes serialize the "m" into bytes using the gob encoder and returns the result. -func SerializeBytes(m interface{}) ([]byte, error) { - buf := new(bytes.Buffer) - enc := gob.NewEncoder(buf) - err := enc.Encode(m) - if err == nil { - return buf.Bytes(), nil - } - return nil, err -} - -// DeserializeBytes converts the bytes to a go value and puts that to "m" using the gob decoder. -func DeserializeBytes(b []byte, m interface{}) error { - dec := gob.NewDecoder(bytes.NewBuffer(b)) - return dec.Decode(m) //no reference here otherwise doesn't work because of go remote object + db.redis.Set(p.SessionID, storeB) } diff --git a/sessions/sessiondb/redis/service/service.go b/sessions/sessiondb/redis/service/service.go index 6c224afb..94c037e4 100644 --- a/sessions/sessiondb/redis/service/service.go +++ b/sessions/sessiondb/redis/service/service.go @@ -42,21 +42,21 @@ func (r *Service) CloseConnection() error { return ErrRedisClosed } -// Set sets to the redis -// key string, value string, you can use utils.Serialize(&myobject{}) to convert an object to []byte -func (r *Service) Set(key string, value []byte) (err error) { // map[interface{}]interface{}) (err error) { +// Set sets a key-value to the redis store. +// The expiration is setted by the MaxAgeSeconds. +func (r *Service) Set(key string, value interface{}) error { c := r.pool.Get() defer c.Close() - if err = c.Err(); err != nil { - return + if c.Err() != nil { + return c.Err() } - _, err = c.Do("SETEX", r.Config.Prefix+key, r.Config.MaxAgeSeconds, value) - return + + _, err := c.Do("SETEX", r.Config.Prefix+key, r.Config.MaxAgeSeconds, value) + return err } // Get returns value, err by its key -// you can use utils.Deserialize((.Get("yourkey"),&theobject{}) -//returns nil and a filled error if something wrong happens +//returns nil and a filled error if something bad happened. func (r *Service) Get(key string) (interface{}, error) { c := r.pool.Get() defer c.Close() @@ -75,6 +75,27 @@ func (r *Service) Get(key string) (interface{}, error) { return redisVal, nil } +// GetAll returns all redis entries using the "SCAN" command (2.8+). +func (r *Service) GetAll() (interface{}, error) { + c := r.pool.Get() + defer c.Close() + if err := c.Err(); err != nil { + return nil, err + } + + redisVal, err := c.Do("SCAN", 0) // 0 -> cursor + + if err != nil { + return nil, err + } + + if redisVal == nil { + return nil, err + } + + return redisVal, nil +} + // GetBytes returns value, err by its key // you can use utils.Deserialize((.GetBytes("yourkey"),&theobject{}) //returns nil and a filled error if something wrong happens @@ -97,95 +118,6 @@ func (r *Service) GetBytes(key string) ([]byte, error) { return redis.Bytes(redisVal, err) } -// GetString returns value, err by its key -// you can use utils.Deserialize((.GetString("yourkey"),&theobject{}) -//returns empty string and a filled error if something wrong happens -func (r *Service) GetString(key string) (string, error) { - redisVal, err := r.Get(key) - if redisVal == nil { - return "", ErrKeyNotFound.Format(key) - } - - sVal, err := redis.String(redisVal, err) - if err != nil { - return "", err - } - return sVal, nil -} - -// GetInt returns value, err by its key -// you can use utils.Deserialize((.GetInt("yourkey"),&theobject{}) -//returns -1 int and a filled error if something wrong happens -func (r *Service) GetInt(key string) (int, error) { - redisVal, err := r.Get(key) - if redisVal == nil { - return -1, ErrKeyNotFound.Format(key) - } - - intVal, err := redis.Int(redisVal, err) - if err != nil { - return -1, err - } - return intVal, nil -} - -// GetStringMap returns map[string]string, err by its key -//returns nil and a filled error if something wrong happens -func (r *Service) GetStringMap(key string) (map[string]string, error) { - redisVal, err := r.Get(key) - if redisVal == nil { - return nil, ErrKeyNotFound.Format(key) - } - - _map, err := redis.StringMap(redisVal, err) - if err != nil { - return nil, err - } - return _map, nil -} - -// GetAll returns all keys and their values from a specific key (map[string]string) -// returns a filled error if something bad happened -func (r *Service) GetAll(key string) (map[string]string, error) { - c := r.pool.Get() - defer c.Close() - if err := c.Err(); err != nil { - return nil, err - } - - reply, err := c.Do("HGETALL", r.Config.Prefix+key) - - if err != nil { - return nil, err - } - if reply == nil { - return nil, ErrKeyNotFound.Format(key) - } - - return redis.StringMap(reply, err) - -} - -// GetAllKeysByPrefix returns all []string keys by a key prefix from the redis -func (r *Service) GetAllKeysByPrefix(prefix string) ([]string, error) { - c := r.pool.Get() - defer c.Close() - if err := c.Err(); err != nil { - return nil, err - } - - reply, err := c.Do("KEYS", r.Config.Prefix+prefix) - - if err != nil { - return nil, err - } - if reply == nil { - return nil, ErrKeyNotFound.Format(prefix) - } - return redis.Strings(reply, err) - -} - // Delete removes redis entry by specific key func (r *Service) Delete(key string) error { c := r.pool.Get() diff --git a/sessions/sessions.go b/sessions/sessions.go index 13ad6ec5..680e7c6e 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -36,7 +36,7 @@ func (s *Sessions) UseDatabase(db Database) { } // updateCookie gains the ability of updating the session browser cookie to any method which wants to update it -func (s *Sessions) updateCookie(sid string, ctx context.Context, expires time.Duration) { +func (s *Sessions) updateCookie(ctx context.Context, sid string, expires time.Duration) { cookie := &http.Cookie{} // The RFC makes no mention of encoding url value, so here I think to encode both sessionid key and the value using the safe(to put and to use as cookie) url-encoding @@ -107,9 +107,9 @@ func (s *Sessions) Start(ctx context.Context) *Session { sid := s.config.SessionIDGenerator() sess := s.provider.Init(sid, s.config.Expires) - sess.isNew = len(sess.values) == 0 + sess.isNew = sess.values.Len() == 0 - s.updateCookie(sid, ctx, s.config.Expires) + s.updateCookie(ctx, sid, s.config.Expires) return sess } @@ -119,18 +119,20 @@ func (s *Sessions) Start(ctx context.Context) *Session { return sess } -// ShiftExpiraton move the expire date of a session to a new date by using session default timeout configuration +// ShiftExpiraton move the expire date of a session to a new date +// by using session default timeout configuration. func (s *Sessions) ShiftExpiraton(ctx context.Context) { s.UpdateExpiraton(ctx, s.config.Expires) } -// UpdateExpiraton change expire date of a session to a new date by using timeout value passed by `expires` parameter +// UpdateExpiraton change expire date of a session to a new date +// by using timeout value passed by `expires` receiver. func (s *Sessions) UpdateExpiraton(ctx context.Context, expires time.Duration) { cookieValue := s.decodeCookieValue(GetCookie(ctx, s.config.Cookie)) if cookieValue != "" { if s.provider.UpdateExpiraton(cookieValue, expires) { - s.updateCookie(cookieValue, ctx, expires) + s.updateCookie(ctx, cookieValue, expires) } } }