├── .gitignore ├── CODEOWNERS ├── codecov.yml ├── .editorconfig ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ └── go.yml ├── .golangci.yml ├── manager_test.go ├── LICENSE ├── README.md ├── go.mod ├── memory_test.go ├── type.go ├── manager.go ├── file_test.go ├── redis ├── redis.go └── redis_test.go ├── sqlite ├── sqlite_test.go └── sqlite.go ├── mongo ├── mongo_test.go └── mongo.go ├── session_test.go ├── file.go ├── postgres ├── postgres.go └── postgres_test.go ├── mysql ├── mysql_test.go └── mysql.go ├── session.go ├── memory.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | .envrc 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Default 2 | * @flamego/core 3 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: "60...95" 3 | status: 4 | project: 5 | default: 6 | threshold: 1% 7 | 8 | comment: 9 | layout: 'diff' 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Docs: https://git.io/JCUAY 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | commit-message: 9 | prefix: "mod:" 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Describe the pull request 2 | 3 | A clear and concise description of what the pull request is about, i.e. what problem should be fixed? 4 | 5 | Link to the issue: 6 | 7 | ### Checklist 8 | 9 | - [ ] I agree to follow the [Code of Conduct](https://go.dev/conduct) by submitting this pull request. 10 | - [ ] I have read and acknowledge the [Contributing guide](https://github.com/flamego/flamego/blob/main/.github/contributing.md). 11 | - [ ] I have added test cases to cover the new code. 12 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - nakedret 5 | - rowserrcheck 6 | - unconvert 7 | - unparam 8 | settings: 9 | nakedret: 10 | max-func-lines: 0 # Disallow any unnamed return statement 11 | exclusions: 12 | generated: lax 13 | presets: 14 | - comments 15 | - common-false-positives 16 | - legacy 17 | - std-error-handling 18 | paths: 19 | - third_party$ 20 | - builtin$ 21 | - examples$ 22 | formatters: 23 | enable: 24 | - gofmt 25 | - goimports 26 | exclusions: 27 | generated: lax 28 | paths: 29 | - third_party$ 30 | - builtin$ 31 | - examples$ 32 | -------------------------------------------------------------------------------- /manager_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package session 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestIsValidSessionID(t *testing.T) { 17 | for i := 0; i < 10; i++ { 18 | s, err := randomChars(16) 19 | require.Nil(t, err) 20 | assert.True(t, isValidSessionID(s, 16)) 21 | } 22 | 23 | assert.False(t, isValidSessionID("123", 16)) 24 | assert.False(t, isValidSessionID("3qKCBYmuAqG1RQix", 16)) 25 | assert.False(t, isValidSessionID("../session/ad2c7", 16)) 26 | } 27 | 28 | func TestManager_startGC(t *testing.T) { 29 | m := newManager(newMemoryStore(MemoryConfig{}, nil)) 30 | stop := m.startGC( 31 | context.Background(), 32 | time.Minute, 33 | func(error) { panic("unreachable") }, 34 | ) 35 | stop <- struct{}{} 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Flamego 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # session 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/flamego/session/go.yml?branch=main&logo=github&style=for-the-badge)](https://github.com/flamego/session/actions?query=workflow%3AGo) 4 | [![GoDoc](https://img.shields.io/badge/GoDoc-Reference-blue?style=for-the-badge&logo=go)](https://pkg.go.dev/github.com/flamego/session?tab=doc) 5 | 6 | Package session is a middleware that provides the session management for [Flamego](https://github.com/flamego/flamego). 7 | 8 | ## Installation 9 | 10 | ```zsh 11 | go get github.com/flamego/session 12 | ``` 13 | 14 | ## Getting started 15 | 16 | ```go 17 | package main 18 | 19 | import ( 20 | "github.com/flamego/flamego" 21 | "github.com/flamego/session" 22 | ) 23 | 24 | func main() { 25 | f := flamego.Classic() 26 | f.Use(session.Sessioner()) 27 | f.Get("/", func(s session.Session) { 28 | s.Set("user_id", 123) 29 | userID, ok := s.Get("user_id").(int) 30 | // ... 31 | }) 32 | f.Run() 33 | } 34 | ``` 35 | 36 | ## Getting help 37 | 38 | - Read [documentation and examples](https://flamego.dev/middleware/session.html). 39 | - Please [file an issue](https://github.com/flamego/flamego/issues) or [start a discussion](https://github.com/flamego/flamego/discussions) on the [flamego/flamego](https://github.com/flamego/flamego) repository. 40 | 41 | ## License 42 | 43 | This project is under the MIT License. See the [LICENSE](LICENSE) file for the full license text. 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/flamego/session 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/flamego/flamego v1.9.7 7 | github.com/go-sql-driver/mysql v1.9.3 8 | github.com/jackc/pgx/v5 v5.7.5 9 | github.com/pkg/errors v0.9.1 10 | github.com/redis/go-redis/v9 v9.17.2 11 | github.com/stretchr/testify v1.11.1 12 | go.mongodb.org/mongo-driver v1.17.6 13 | modernc.org/sqlite v1.40.1 14 | ) 15 | 16 | require ( 17 | filippo.io/edwards25519 v1.1.0 // indirect 18 | github.com/alecthomas/participle/v2 v2.1.4 // indirect 19 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 21 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 22 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 23 | github.com/charmbracelet/log v0.4.2 // indirect 24 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 25 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 26 | github.com/charmbracelet/x/term v0.2.1 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 29 | github.com/dustin/go-humanize v1.0.1 // indirect 30 | github.com/go-logfmt/logfmt v0.6.0 // indirect 31 | github.com/golang/snappy v0.0.4 // indirect 32 | github.com/google/uuid v1.6.0 // indirect 33 | github.com/jackc/pgpassfile v1.0.0 // indirect 34 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 35 | github.com/jackc/puddle/v2 v2.2.2 // indirect 36 | github.com/klauspost/compress v1.16.7 // indirect 37 | github.com/kr/text v0.2.0 // indirect 38 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 39 | github.com/mattn/go-isatty v0.0.20 // indirect 40 | github.com/mattn/go-runewidth v0.0.16 // indirect 41 | github.com/montanaflynn/stats v0.7.1 // indirect 42 | github.com/muesli/termenv v0.16.0 // indirect 43 | github.com/ncruces/go-strftime v0.1.9 // indirect 44 | github.com/pmezard/go-difflib v1.0.0 // indirect 45 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 46 | github.com/rivo/uniseg v0.4.7 // indirect 47 | github.com/rogpeppe/go-internal v1.6.1 // indirect 48 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 49 | github.com/xdg-go/scram v1.1.2 // indirect 50 | github.com/xdg-go/stringprep v1.0.4 // indirect 51 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 52 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect 53 | golang.org/x/crypto v0.37.0 // indirect 54 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 55 | golang.org/x/sync v0.16.0 // indirect 56 | golang.org/x/sys v0.36.0 // indirect 57 | golang.org/x/text v0.24.0 // indirect 58 | gopkg.in/yaml.v3 v3.0.1 // indirect 59 | modernc.org/libc v1.66.10 // indirect 60 | modernc.org/mathutil v1.7.1 // indirect 61 | modernc.org/memory v1.11.0 // indirect 62 | ) 63 | -------------------------------------------------------------------------------- /memory_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package session 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "net/http" 11 | "net/http/httptest" 12 | "testing" 13 | "time" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | 18 | "github.com/flamego/flamego" 19 | ) 20 | 21 | func TestMemoryStore(t *testing.T) { 22 | f := flamego.NewWithLogger(&bytes.Buffer{}) 23 | f.Use(Sessioner()) 24 | 25 | f.Get("/set", func(s Session) { 26 | s.Set("username", "flamego") 27 | }) 28 | f.Get("/get", func(s Session) { 29 | sid := s.ID() 30 | assert.Len(t, sid, 16) 31 | 32 | username, ok := s.Get("username").(string) 33 | assert.True(t, ok) 34 | assert.Equal(t, "flamego", username) 35 | 36 | s.Delete("username") 37 | _, ok = s.Get("username").(string) 38 | assert.False(t, ok) 39 | 40 | s.Set("random", "value") 41 | s.Flush() 42 | _, ok = s.Get("random").(string) 43 | assert.False(t, ok) 44 | }) 45 | f.Get("/destroy", func(c flamego.Context, session Session, store Store) error { 46 | return store.Destroy(c.Request().Context(), session.ID()) 47 | }) 48 | 49 | resp := httptest.NewRecorder() 50 | req, err := http.NewRequest(http.MethodGet, "/set", nil) 51 | require.Nil(t, err) 52 | 53 | f.ServeHTTP(resp, req) 54 | assert.Equal(t, http.StatusOK, resp.Code) 55 | 56 | cookie := resp.Header().Get("Set-Cookie") 57 | 58 | resp = httptest.NewRecorder() 59 | req, err = http.NewRequest(http.MethodGet, "/get", nil) 60 | require.Nil(t, err) 61 | 62 | req.Header.Set("Cookie", cookie) 63 | f.ServeHTTP(resp, req) 64 | assert.Equal(t, http.StatusOK, resp.Code) 65 | 66 | resp = httptest.NewRecorder() 67 | req, err = http.NewRequest(http.MethodGet, "/destroy", nil) 68 | require.Nil(t, err) 69 | 70 | req.Header.Set("Cookie", cookie) 71 | f.ServeHTTP(resp, req) 72 | assert.Equal(t, http.StatusOK, resp.Code) 73 | } 74 | 75 | func TestMemoryStore_GC(t *testing.T) { 76 | ctx := context.Background() 77 | now := time.Now() 78 | store := newMemoryStore( 79 | MemoryConfig{ 80 | nowFunc: func() time.Time { return now }, 81 | Lifetime: time.Second, 82 | }, 83 | nil, 84 | ) 85 | 86 | sess1, err := store.Read(ctx, "1") 87 | require.Nil(t, err) 88 | 89 | now = now.Add(-2 * time.Second) 90 | sess2, err := store.Read(ctx, "2") 91 | require.Nil(t, err) 92 | 93 | sess2.Set("name", "flamego") 94 | err = store.Save(ctx, sess2) 95 | require.Nil(t, err) 96 | 97 | // Read on an expired session should wipe data but preserve the record 98 | now = now.Add(2 * time.Second) 99 | tmp, err := store.Read(ctx, "2") 100 | require.Nil(t, err) 101 | assert.Nil(t, tmp.Get("name")) 102 | 103 | now = now.Add(-2 * time.Second) 104 | _, err = store.Read(ctx, "3") 105 | require.Nil(t, err) 106 | 107 | now = now.Add(2 * time.Second) 108 | err = store.GC(ctx) // sess3 should be recycled 109 | require.Nil(t, err) 110 | 111 | wantHeap := []*memorySession{sess2.(*memorySession), sess1.(*memorySession)} 112 | assert.Equal(t, wantHeap, store.heap) 113 | 114 | wantIndex := map[string]*memorySession{ 115 | "1": sess1.(*memorySession), 116 | "2": sess2.(*memorySession), 117 | } 118 | assert.Equal(t, wantIndex, store.index) 119 | } 120 | 121 | func TestMemoryStore_Touch(t *testing.T) { 122 | ctx := context.Background() 123 | now := time.Now() 124 | store := newMemoryStore( 125 | MemoryConfig{ 126 | nowFunc: func() time.Time { return now }, 127 | Lifetime: time.Second, 128 | }, 129 | nil, 130 | ) 131 | 132 | sess, err := store.Read(ctx, "1") 133 | require.Nil(t, err) 134 | 135 | now = now.Add(2 * time.Second) 136 | // Touch should keep the session alive 137 | err = store.Touch(ctx, sess.ID()) 138 | require.Nil(t, err) 139 | 140 | err = store.GC(ctx) 141 | require.Nil(t, err) 142 | 143 | wantHeap := []*memorySession{sess.(*memorySession)} 144 | assert.Equal(t, wantHeap, store.heap) 145 | } 146 | -------------------------------------------------------------------------------- /type.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package session 6 | 7 | import ( 8 | "bytes" 9 | "encoding/gob" 10 | "net/http" 11 | "sync" 12 | 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | // Data is the data structure for storing session data. 17 | type Data map[interface{}]interface{} 18 | 19 | // Encoder is an encoder to encode session data to binary. 20 | type Encoder func(Data) ([]byte, error) 21 | 22 | // Decoder is a decoder to decode binary to session data. 23 | type Decoder func([]byte) (Data, error) 24 | 25 | // IDWriter is a function that writes the session ID to client (browser). 26 | type IDWriter func(w http.ResponseWriter, r *http.Request, sid string) 27 | 28 | var _ Session = (*BaseSession)(nil) 29 | 30 | // BaseSession implements basic operations for the session data. 31 | type BaseSession struct { 32 | sid string // The session ID 33 | lock sync.RWMutex // The mutex to guard accesses to the data 34 | data Data // The map of the session data 35 | changed bool // Whether the session has changed since read 36 | 37 | encoder Encoder 38 | idWriter IDWriter 39 | } 40 | 41 | // NewBaseSession returns a new BaseSession with given session ID. 42 | func NewBaseSession(sid string, encoder Encoder, idWriter IDWriter) *BaseSession { 43 | return &BaseSession{ 44 | sid: sid, 45 | data: make(Data), 46 | encoder: encoder, 47 | idWriter: idWriter, 48 | } 49 | } 50 | 51 | // NewBaseSessionWithData returns a new BaseSession with given session ID and 52 | // initial data. 53 | func NewBaseSessionWithData(sid string, encoder Encoder, idWriter IDWriter, data Data) *BaseSession { 54 | return &BaseSession{ 55 | sid: sid, 56 | data: data, 57 | encoder: encoder, 58 | idWriter: idWriter, 59 | } 60 | } 61 | 62 | func (s *BaseSession) ID() string { 63 | return s.sid 64 | } 65 | 66 | func (s *BaseSession) RegenerateID(w http.ResponseWriter, r *http.Request) error { 67 | s.lock.Lock() 68 | defer s.lock.Unlock() 69 | 70 | // Re-use the session ID with the same length, the length must already be valid 71 | // for the code to run to this point. 72 | sid, err := randomChars(len(s.sid)) 73 | if err != nil { 74 | return errors.Wrap(err, "new ID") 75 | } 76 | 77 | s.idWriter(w, r, sid) 78 | s.sid = sid 79 | return nil 80 | } 81 | 82 | func (s *BaseSession) Get(key interface{}) interface{} { 83 | s.lock.RLock() 84 | defer s.lock.RUnlock() 85 | return s.data[key] 86 | } 87 | 88 | func (s *BaseSession) Set(key, val interface{}) { 89 | s.lock.Lock() 90 | defer s.lock.Unlock() 91 | s.changed = true 92 | s.data[key] = val 93 | } 94 | 95 | func (s *BaseSession) SetFlash(val interface{}) { 96 | s.lock.Lock() 97 | defer s.lock.Unlock() 98 | s.changed = true 99 | s.data[flashKey] = val 100 | } 101 | 102 | func (s *BaseSession) Delete(key interface{}) { 103 | s.lock.Lock() 104 | defer s.lock.Unlock() 105 | s.changed = true 106 | delete(s.data, key) 107 | } 108 | 109 | func (s *BaseSession) Flush() { 110 | s.lock.Lock() 111 | defer s.lock.Unlock() 112 | s.changed = true 113 | s.data = make(Data) 114 | } 115 | 116 | func (s *BaseSession) Encode() ([]byte, error) { 117 | s.lock.RLock() 118 | defer s.lock.RUnlock() 119 | return s.encoder(s.data) 120 | } 121 | 122 | func (s *BaseSession) HasChanged() bool { 123 | s.lock.RLock() 124 | defer s.lock.RUnlock() 125 | return s.changed 126 | } 127 | 128 | // GobEncoder is a session data encoder using Gob. 129 | func GobEncoder(data Data) ([]byte, error) { 130 | var buf bytes.Buffer 131 | err := gob.NewEncoder(&buf).Encode(data) 132 | if err != nil { 133 | return nil, err 134 | } 135 | return buf.Bytes(), nil 136 | } 137 | 138 | // GobDecoder is a session data decoder using Gob. 139 | func GobDecoder(binary []byte) (Data, error) { 140 | buf := bytes.NewBuffer(binary) 141 | var data Data 142 | return data, gob.NewDecoder(buf).Decode(&data) 143 | } 144 | 145 | // Flash is anything that gets retrieved and deleted as soon as the next request 146 | // happens. 147 | type Flash interface{} 148 | 149 | const flashKey = "flamego::session::flash" 150 | -------------------------------------------------------------------------------- /manager.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package session 6 | 7 | import ( 8 | "context" 9 | "crypto/rand" 10 | "math/big" 11 | "net/http" 12 | "time" 13 | 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | // Store is a session store with capabilities of checking, reading, destroying 18 | // and GC sessions. 19 | type Store interface { 20 | // Exist returns true of the session with given ID exists. 21 | Exist(ctx context.Context, sid string) bool 22 | // Read returns the session with given ID. If a session with the ID does not 23 | // exist, a new session with the same ID is created and returned. 24 | Read(ctx context.Context, sid string) (Session, error) 25 | // Destroy deletes session with given ID from the session store completely. 26 | Destroy(ctx context.Context, sid string) error 27 | // Touch updates the expiry time of the session with given ID. It does nothing 28 | // if there is no session associated with the ID. 29 | Touch(ctx context.Context, sid string) error 30 | // Save persists session data to the session store. 31 | Save(ctx context.Context, session Session) error 32 | // GC performs a GC operation on the session store. 33 | GC(ctx context.Context) error 34 | } 35 | 36 | // Initer takes arbitrary number of arguments needed for initialization and 37 | // returns an initialized session store. 38 | type Initer func(ctx context.Context, args ...interface{}) (Store, error) 39 | 40 | // manager is wrapper for wiring HTTP request and session stores. 41 | type manager struct { 42 | store Store // The session store that is being managed. 43 | } 44 | 45 | // newManager returns a new manager with given session store. 46 | func newManager(store Store) *manager { 47 | return &manager{ 48 | store: store, 49 | } 50 | } 51 | 52 | // startGC starts a background goroutine to trigger GC of the session store in 53 | // given time interval. Errors are printed using the `errFunc`. It returns a 54 | // send-only channel for stopping the background goroutine. 55 | func (m *manager) startGC(ctx context.Context, interval time.Duration, errFunc func(error)) chan<- struct{} { 56 | stop := make(chan struct{}) 57 | go func() { 58 | ticker := time.NewTicker(interval) 59 | for { 60 | err := m.store.GC(ctx) 61 | if err != nil { 62 | errFunc(err) 63 | } 64 | 65 | select { 66 | case <-stop: 67 | ticker.Stop() 68 | return 69 | case <-ticker.C: 70 | } 71 | } 72 | }() 73 | return stop 74 | } 75 | 76 | // randomChars returns a generated string in given number of random characters. 77 | func randomChars(n int) (string, error) { 78 | const alphanum = "0123456789abcdefghijklmnopqrstuvwxyz" 79 | 80 | randomInt := func(max *big.Int) (int, error) { 81 | r, err := rand.Int(rand.Reader, max) 82 | if err != nil { 83 | return 0, err 84 | } 85 | 86 | return int(r.Int64()), nil 87 | } 88 | 89 | buffer := make([]byte, n) 90 | max := big.NewInt(int64(len(alphanum))) 91 | for i := 0; i < n; i++ { 92 | index, err := randomInt(max) 93 | if err != nil { 94 | return "", err 95 | } 96 | 97 | buffer[i] = alphanum[index] 98 | } 99 | 100 | return string(buffer), nil 101 | } 102 | 103 | // isValidSessionID returns true if given session ID looks like a valid ID. 104 | func isValidSessionID(sid string, idLength int) bool { 105 | if len(sid) != idLength { 106 | return false 107 | } 108 | 109 | for i := range sid { 110 | switch { 111 | case '0' <= sid[i] && sid[i] <= '9': 112 | case 'a' <= sid[i] && sid[i] <= 'z': 113 | default: 114 | return false 115 | } 116 | } 117 | return true 118 | } 119 | 120 | // load loads the session from the session store with session ID provided in the 121 | // named cookie. It returns `created=true` if a new session is created. 122 | func (m *manager) load(r *http.Request, sid string, idLength int) (_ Session, created bool, err error) { 123 | if !isValidSessionID(sid, idLength) { 124 | sid, err = randomChars(idLength) 125 | if err != nil { 126 | return nil, false, errors.Wrap(err, "new ID") 127 | } 128 | created = true 129 | } 130 | 131 | sess, err := m.store.Read(r.Context(), sid) 132 | if err != nil { 133 | return nil, false, errors.Wrap(err, "read") 134 | } 135 | return sess, created, nil 136 | } 137 | -------------------------------------------------------------------------------- /file_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package session 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "net/http" 11 | "net/http/httptest" 12 | "os" 13 | "path/filepath" 14 | "testing" 15 | "time" 16 | 17 | "github.com/stretchr/testify/assert" 18 | "github.com/stretchr/testify/require" 19 | 20 | "github.com/flamego/flamego" 21 | ) 22 | 23 | func TestFileStore(t *testing.T) { 24 | f := flamego.NewWithLogger(&bytes.Buffer{}) 25 | f.Use(Sessioner( 26 | Options{ 27 | Initer: FileIniter(), 28 | Config: FileConfig{ 29 | nowFunc: time.Now, 30 | RootDir: filepath.Join(os.TempDir(), "sessions"), 31 | }, 32 | }, 33 | )) 34 | 35 | f.Get("/set", func(s Session) { 36 | s.Set("username", "flamego") 37 | }) 38 | f.Get("/get", func(s Session) { 39 | sid := s.ID() 40 | assert.Len(t, sid, 16) 41 | 42 | username, ok := s.Get("username").(string) 43 | assert.True(t, ok) 44 | assert.Equal(t, "flamego", username) 45 | 46 | s.Delete("username") 47 | _, ok = s.Get("username").(string) 48 | assert.False(t, ok) 49 | 50 | s.Set("random", "value") 51 | s.Flush() 52 | _, ok = s.Get("random").(string) 53 | assert.False(t, ok) 54 | }) 55 | f.Get("/destroy", func(c flamego.Context, session Session, store Store) error { 56 | return store.Destroy(c.Request().Context(), session.ID()) 57 | }) 58 | 59 | resp := httptest.NewRecorder() 60 | req, err := http.NewRequest("GET", "/set", nil) 61 | require.Nil(t, err) 62 | 63 | f.ServeHTTP(resp, req) 64 | assert.Equal(t, http.StatusOK, resp.Code) 65 | 66 | cookie := resp.Header().Get("Set-Cookie") 67 | 68 | resp = httptest.NewRecorder() 69 | req, err = http.NewRequest("GET", "/get", nil) 70 | require.Nil(t, err) 71 | 72 | req.Header.Set("Cookie", cookie) 73 | f.ServeHTTP(resp, req) 74 | assert.Equal(t, http.StatusOK, resp.Code) 75 | 76 | resp = httptest.NewRecorder() 77 | req, err = http.NewRequest("GET", "/destroy", nil) 78 | require.Nil(t, err) 79 | 80 | req.Header.Set("Cookie", cookie) 81 | f.ServeHTTP(resp, req) 82 | assert.Equal(t, http.StatusOK, resp.Code) 83 | } 84 | 85 | func TestFileStore_GC(t *testing.T) { 86 | ctx := context.Background() 87 | now := time.Now() 88 | store, err := FileIniter()(ctx, 89 | FileConfig{ 90 | nowFunc: func() time.Time { return now }, 91 | RootDir: filepath.Join(os.TempDir(), "sessions"), 92 | Lifetime: time.Second, 93 | }, 94 | IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 95 | ) 96 | require.Nil(t, err) 97 | 98 | sess1, err := store.Read(ctx, "111") 99 | require.Nil(t, err) 100 | err = store.Save(ctx, sess1) 101 | require.Nil(t, err) 102 | 103 | now = now.Add(-2 * time.Second) 104 | sess2, err := store.Read(ctx, "222") 105 | require.Nil(t, err) 106 | 107 | sess2.Set("name", "flamego") 108 | err = store.Save(ctx, sess2) 109 | require.Nil(t, err) 110 | 111 | // Read on an expired session should wipe data but preserve the record 112 | now = now.Add(2 * time.Second) 113 | tmp, err := store.Read(ctx, "222") 114 | require.Nil(t, err) 115 | assert.Nil(t, tmp.Get("name")) 116 | 117 | now = now.Add(-2 * time.Second) 118 | sess3, err := store.Read(ctx, "333") 119 | require.Nil(t, err) 120 | err = store.Save(ctx, sess3) 121 | require.Nil(t, err) 122 | 123 | now = now.Add(2 * time.Second) 124 | err = store.GC(ctx) // sess3 should be recycled 125 | require.Nil(t, err) 126 | 127 | assert.True(t, store.Exist(ctx, "111")) 128 | assert.False(t, store.Exist(ctx, "222")) 129 | assert.False(t, store.Exist(ctx, "333")) 130 | } 131 | 132 | func TestFileStore_Touch(t *testing.T) { 133 | ctx := context.Background() 134 | now := time.Now() 135 | store, err := FileIniter()(ctx, 136 | FileConfig{ 137 | nowFunc: func() time.Time { return now }, 138 | RootDir: filepath.Join(os.TempDir(), "sessions"), 139 | Lifetime: time.Second, 140 | }, 141 | IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 142 | ) 143 | require.Nil(t, err) 144 | 145 | sess, err := store.Read(ctx, "111") 146 | require.Nil(t, err) 147 | err = store.Save(ctx, sess) 148 | require.Nil(t, err) 149 | 150 | now = now.Add(2 * time.Second) 151 | // Touch should keep the session alive 152 | err = store.Touch(ctx, sess.ID()) 153 | require.Nil(t, err) 154 | 155 | err = store.GC(ctx) 156 | require.Nil(t, err) 157 | assert.True(t, store.Exist(ctx, sess.ID())) 158 | } 159 | -------------------------------------------------------------------------------- /redis/redis.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package redis 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "time" 11 | 12 | "github.com/pkg/errors" 13 | "github.com/redis/go-redis/v9" 14 | 15 | "github.com/flamego/session" 16 | ) 17 | 18 | var _ session.Store = (*redisStore)(nil) 19 | 20 | // redisStore is a Redis implementation of the session store. 21 | type redisStore struct { 22 | client *redis.Client // The client connection 23 | keyPrefix string // The prefix to use for keys 24 | lifetime time.Duration // The duration to have access to a session before being recycled 25 | 26 | encoder session.Encoder 27 | decoder session.Decoder 28 | idWriter session.IDWriter 29 | } 30 | 31 | // newRedisStore returns a new Redis session store based on given configuration. 32 | func newRedisStore(cfg Config, idWriter session.IDWriter) *redisStore { 33 | return &redisStore{ 34 | client: cfg.Client, 35 | keyPrefix: cfg.KeyPrefix, 36 | lifetime: cfg.Lifetime, 37 | encoder: cfg.Encoder, 38 | decoder: cfg.Decoder, 39 | idWriter: idWriter, 40 | } 41 | } 42 | 43 | func (s *redisStore) Exist(ctx context.Context, sid string) bool { 44 | result, err := s.client.Exists(ctx, s.keyPrefix+sid).Result() 45 | return err == nil && result == 1 46 | } 47 | 48 | func (s *redisStore) Read(ctx context.Context, sid string) (session.Session, error) { 49 | binary, err := s.client.Get(ctx, s.keyPrefix+sid).Result() 50 | if err != nil { 51 | if errors.Is(err, redis.Nil) { 52 | return session.NewBaseSession(sid, s.encoder, s.idWriter), nil 53 | } 54 | return nil, errors.Wrap(err, "get") 55 | } 56 | 57 | data, err := s.decoder([]byte(binary)) 58 | if err != nil { 59 | return nil, errors.Wrap(err, "decode") 60 | } 61 | return session.NewBaseSessionWithData(sid, s.encoder, s.idWriter, data), nil 62 | } 63 | 64 | func (s *redisStore) Destroy(ctx context.Context, sid string) error { 65 | return s.client.Del(ctx, s.keyPrefix+sid).Err() 66 | } 67 | 68 | func (s *redisStore) Touch(ctx context.Context, sid string) error { 69 | err := s.client.Expire(ctx, s.keyPrefix+sid, s.lifetime).Err() 70 | if err != nil { 71 | return errors.Wrap(err, "expire") 72 | } 73 | return nil 74 | } 75 | 76 | func (s *redisStore) Save(ctx context.Context, sess session.Session) error { 77 | binary, err := sess.Encode() 78 | if err != nil { 79 | return errors.Wrap(err, "encode") 80 | } 81 | 82 | err = s.client.SetEx(ctx, s.keyPrefix+sess.ID(), binary, s.lifetime).Err() 83 | if err != nil { 84 | return errors.Wrap(err, "set") 85 | } 86 | return nil 87 | } 88 | 89 | func (s *redisStore) GC(_ context.Context) error { 90 | return nil 91 | } 92 | 93 | // Options keeps the settings to set up Redis client connection. 94 | type Options = redis.Options 95 | 96 | // Config contains options for the Redis session store. 97 | type Config struct { 98 | // Client is the Redis Client connection. If not set, a new client will be 99 | // created based on Options. 100 | Client *redis.Client 101 | // Options is the settings to set up Redis client connection. 102 | Options *Options 103 | // KeyPrefix is the prefix to use for keys in Redis. Default is "session:". 104 | KeyPrefix string 105 | // Lifetime is the duration to have no access to a session before being 106 | // recycled. Default is 3600 seconds. 107 | Lifetime time.Duration 108 | // Encoder is the encoder to encode session data. Default is session.GobEncoder. 109 | Encoder session.Encoder 110 | // Decoder is the decoder to decode session data. Default is session.GobDecoder. 111 | Decoder session.Decoder 112 | } 113 | 114 | // Initer returns the session.Initer for the Redis session store. 115 | func Initer() session.Initer { 116 | return func(ctx context.Context, args ...interface{}) (session.Store, error) { 117 | var cfg *Config 118 | var idWriter session.IDWriter 119 | for i := range args { 120 | switch v := args[i].(type) { 121 | case Config: 122 | cfg = &v 123 | case session.IDWriter: 124 | idWriter = v 125 | } 126 | } 127 | if idWriter == nil { 128 | return nil, errors.New("IDWriter not given") 129 | } 130 | 131 | if cfg == nil { 132 | return nil, fmt.Errorf("config object with the type '%T' not found", Config{}) 133 | } else if cfg.Options == nil && cfg.Client == nil { 134 | return nil, errors.New("empty Options") 135 | } 136 | 137 | if cfg.Client == nil { 138 | cfg.Client = redis.NewClient(cfg.Options) 139 | } 140 | if cfg.KeyPrefix == "" { 141 | cfg.KeyPrefix = "session:" 142 | } 143 | if cfg.Lifetime.Seconds() < 1 { 144 | cfg.Lifetime = 3600 * time.Second 145 | } 146 | if cfg.Encoder == nil { 147 | cfg.Encoder = session.GobEncoder 148 | } 149 | if cfg.Decoder == nil { 150 | cfg.Decoder = session.GobDecoder 151 | } 152 | 153 | return newRedisStore(*cfg, idWriter), nil 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /redis/redis_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package redis 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "net/http" 11 | "net/http/httptest" 12 | "os" 13 | "testing" 14 | "time" 15 | 16 | "github.com/flamego/flamego" 17 | "github.com/redis/go-redis/v9" 18 | "github.com/stretchr/testify/assert" 19 | "github.com/stretchr/testify/require" 20 | 21 | "github.com/flamego/session" 22 | ) 23 | 24 | func newTestClient(t *testing.T, ctx context.Context) (testClient *redis.Client, cleanup func() error) { 25 | const db = 15 26 | testClient = redis.NewClient( 27 | &redis.Options{ 28 | Addr: os.ExpandEnv("$REDIS_HOST:$REDIS_PORT"), 29 | DB: db, 30 | }, 31 | ) 32 | 33 | err := testClient.FlushDB(ctx).Err() 34 | if err != nil { 35 | t.Fatalf("Failed to flush test database: %v", err) 36 | } 37 | 38 | t.Cleanup(func() { 39 | defer func() { _ = testClient.Close() }() 40 | 41 | if t.Failed() { 42 | t.Logf("DATABASE %d left intact for inspection", db) 43 | return 44 | } 45 | 46 | err := testClient.FlushDB(ctx).Err() 47 | if err != nil { 48 | t.Fatalf("Failed to flush test database: %v", err) 49 | } 50 | }) 51 | return testClient, func() error { 52 | if t.Failed() { 53 | return nil 54 | } 55 | 56 | err := testClient.FlushDB(ctx).Err() 57 | if err != nil { 58 | return err 59 | } 60 | return nil 61 | } 62 | } 63 | 64 | func TestRedisStore(t *testing.T) { 65 | ctx := context.Background() 66 | client, cleanup := newTestClient(t, ctx) 67 | t.Cleanup(func() { 68 | assert.Nil(t, cleanup()) 69 | }) 70 | 71 | f := flamego.NewWithLogger(&bytes.Buffer{}) 72 | f.Use(session.Sessioner( 73 | session.Options{ 74 | Initer: Initer(), 75 | Config: Config{ 76 | Client: client, 77 | }, 78 | }, 79 | )) 80 | 81 | f.Get("/set", func(s session.Session) { 82 | s.Set("username", "flamego") 83 | }) 84 | f.Get("/get", func(s session.Session) { 85 | sid := s.ID() 86 | assert.Len(t, sid, 16) 87 | 88 | username, ok := s.Get("username").(string) 89 | assert.True(t, ok) 90 | assert.Equal(t, "flamego", username) 91 | 92 | s.Delete("username") 93 | _, ok = s.Get("username").(string) 94 | assert.False(t, ok) 95 | 96 | s.Set("random", "value") 97 | s.Flush() 98 | _, ok = s.Get("random").(string) 99 | assert.False(t, ok) 100 | }) 101 | f.Get("/destroy", func(c flamego.Context, session session.Session, store session.Store) error { 102 | return store.Destroy(c.Request().Context(), session.ID()) 103 | }) 104 | 105 | resp := httptest.NewRecorder() 106 | req, err := http.NewRequest("GET", "/set", nil) 107 | require.Nil(t, err) 108 | 109 | f.ServeHTTP(resp, req) 110 | assert.Equal(t, http.StatusOK, resp.Code) 111 | 112 | cookie := resp.Header().Get("Set-Cookie") 113 | 114 | resp = httptest.NewRecorder() 115 | req, err = http.NewRequest("GET", "/get", nil) 116 | require.Nil(t, err) 117 | 118 | req.Header.Set("Cookie", cookie) 119 | f.ServeHTTP(resp, req) 120 | assert.Equal(t, http.StatusOK, resp.Code) 121 | 122 | resp = httptest.NewRecorder() 123 | req, err = http.NewRequest("GET", "/destroy", nil) 124 | require.Nil(t, err) 125 | 126 | req.Header.Set("Cookie", cookie) 127 | f.ServeHTTP(resp, req) 128 | assert.Equal(t, http.StatusOK, resp.Code) 129 | } 130 | 131 | func TestRedisStore_GC(t *testing.T) { 132 | ctx := context.Background() 133 | client, cleanup := newTestClient(t, ctx) 134 | t.Cleanup(func() { 135 | assert.Nil(t, cleanup()) 136 | }) 137 | 138 | store, err := Initer()(ctx, 139 | Config{ 140 | Client: client, 141 | Lifetime: time.Second, 142 | }, 143 | session.IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 144 | ) 145 | require.Nil(t, err) 146 | 147 | sess1, err := store.Read(ctx, "1") 148 | require.Nil(t, err) 149 | err = store.Save(ctx, sess1) 150 | require.Nil(t, err) 151 | 152 | // NOTE: Redis is behaving flaky on exact the seconds in CI, so let's wait 100ms 153 | // more. 154 | time.Sleep(1100 * time.Millisecond) 155 | assert.False(t, store.Exist(ctx, "1")) 156 | 157 | sess2, err := store.Read(ctx, "2") 158 | require.Nil(t, err) 159 | 160 | sess2.Set("name", "flamego") 161 | err = store.Save(ctx, sess2) 162 | require.Nil(t, err) 163 | 164 | tmp, err := store.Read(ctx, "2") 165 | require.Nil(t, err) 166 | assert.Equal(t, "flamego", tmp.Get("name")) 167 | } 168 | 169 | func TestRedisStore_Touch(t *testing.T) { 170 | ctx := context.Background() 171 | client, cleanup := newTestClient(t, ctx) 172 | t.Cleanup(func() { 173 | assert.Nil(t, cleanup()) 174 | }) 175 | 176 | store, err := Initer()(ctx, 177 | Config{ 178 | Client: client, 179 | Lifetime: time.Second, 180 | }, 181 | session.IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 182 | ) 183 | require.Nil(t, err) 184 | 185 | sess, err := store.Read(ctx, "1") 186 | require.Nil(t, err) 187 | err = store.Save(ctx, sess) 188 | require.Nil(t, err) 189 | 190 | time.Sleep(500 * time.Millisecond) 191 | err = store.Touch(ctx, sess.ID()) 192 | require.Nil(t, err) 193 | 194 | // NOTE: Redis is behaving flaky on exact the seconds in CI, so let's wait 100ms 195 | // more. 196 | time.Sleep(600 * time.Millisecond) 197 | err = store.GC(ctx) 198 | require.Nil(t, err) 199 | assert.True(t, store.Exist(ctx, sess.ID())) 200 | } 201 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: 3 | push: 4 | branches: [ main ] 5 | paths: 6 | - '**.go' 7 | - 'go.mod' 8 | - '.golangci.yml' 9 | - '.github/workflows/go.yml' 10 | pull_request: 11 | paths: 12 | - '**.go' 13 | - 'go.mod' 14 | - '.golangci.yml' 15 | - '.github/workflows/go.yml' 16 | env: 17 | GOPROXY: "https://proxy.golang.org" 18 | 19 | jobs: 20 | lint: 21 | name: Lint 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | - name: Run golangci-lint 27 | uses: golangci/golangci-lint-action@v7 28 | with: 29 | version: latest 30 | args: --timeout=30m 31 | 32 | test: 33 | name: Test 34 | strategy: 35 | matrix: 36 | go-version: [ 1.24.x ] 37 | platform: [ ubuntu-latest, macos-latest, windows-latest ] 38 | runs-on: ${{ matrix.platform }} 39 | steps: 40 | - name: Install Go 41 | uses: actions/setup-go@v5 42 | with: 43 | go-version: ${{ matrix.go-version }} 44 | - name: Checkout code 45 | uses: actions/checkout@v4 46 | - name: Run tests with coverage 47 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic 48 | 49 | postgres: 50 | name: Postgres 51 | strategy: 52 | matrix: 53 | go-version: [ 1.24.x ] 54 | platform: [ ubuntu-latest ] 55 | runs-on: ${{ matrix.platform }} 56 | services: 57 | postgres: 58 | image: postgres:9.6 59 | env: 60 | POSTGRES_PASSWORD: postgres 61 | options: >- 62 | --health-cmd pg_isready 63 | --health-interval 10s 64 | --health-timeout 5s 65 | --health-retries 5 66 | ports: 67 | - 5432:5432 68 | steps: 69 | - name: Install Go 70 | uses: actions/setup-go@v5 71 | with: 72 | go-version: ${{ matrix.go-version }} 73 | - name: Checkout code 74 | uses: actions/checkout@v4 75 | - name: Run tests with coverage 76 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic ./postgres 77 | env: 78 | PGPORT: 5432 79 | PGHOST: localhost 80 | PGUSER: postgres 81 | PGPASSWORD: postgres 82 | PGSSLMODE: disable 83 | 84 | redis: 85 | name: Redis 86 | strategy: 87 | matrix: 88 | go-version: [ 1.24.x ] 89 | platform: [ ubuntu-latest ] 90 | runs-on: ${{ matrix.platform }} 91 | services: 92 | redis: 93 | image: redis:4 94 | options: >- 95 | --health-cmd "redis-cli ping" 96 | --health-interval 10s 97 | --health-timeout 5s 98 | --health-retries 5 99 | ports: 100 | - 6379:6379 101 | steps: 102 | - name: Install Go 103 | uses: actions/setup-go@v5 104 | with: 105 | go-version: ${{ matrix.go-version }} 106 | - name: Checkout code 107 | uses: actions/checkout@v4 108 | - name: Run tests with coverage 109 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic ./redis 110 | env: 111 | REDIS_HOST: localhost 112 | REDIS_PORT: 6379 113 | 114 | mysql: 115 | name: MySQL 116 | strategy: 117 | matrix: 118 | go-version: [ 1.24.x ] 119 | platform: [ ubuntu-22.04 ] # Use the lowest version possible for backwards compatibility 120 | runs-on: ${{ matrix.platform }} 121 | steps: 122 | - name: Start MySQL server 123 | run: sudo systemctl start mysql 124 | - name: Install Go 125 | uses: actions/setup-go@v5 126 | with: 127 | go-version: ${{ matrix.go-version }} 128 | - name: Checkout code 129 | uses: actions/checkout@v4 130 | - name: Run tests with coverage 131 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic ./mysql 132 | env: 133 | MYSQL_USER: root 134 | MYSQL_PASSWORD: root 135 | MYSQL_HOST: localhost 136 | MYSQL_PORT: 3306 137 | 138 | mongo: 139 | name: Mongo 140 | strategy: 141 | matrix: 142 | go-version: [ 1.24.x ] 143 | platform: [ ubuntu-latest ] 144 | runs-on: ${{ matrix.platform }} 145 | services: 146 | mongodb: 147 | image: mongo:5 148 | env: 149 | MONGO_INITDB_ROOT_USERNAME: root 150 | MONGO_INITDB_ROOT_PASSWORD: password 151 | options: >- 152 | --health-cmd mongo 153 | --health-interval 10s 154 | --health-timeout 5s 155 | --health-retries 5 156 | ports: 157 | - 27017:27017 158 | steps: 159 | - name: Install Go 160 | uses: actions/setup-go@v5 161 | with: 162 | go-version: ${{ matrix.go-version }} 163 | - name: Checkout code 164 | uses: actions/checkout@v4 165 | - name: Run tests with coverage 166 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic ./mongo 167 | env: 168 | MONGODB_URI: mongodb://root:password@localhost:27017 169 | 170 | sqlite: 171 | name: SQLite 172 | strategy: 173 | matrix: 174 | go-version: [ 1.24.x ] 175 | platform: [ ubuntu-latest ] 176 | runs-on: ${{ matrix.platform }} 177 | steps: 178 | - name: Install Go 179 | uses: actions/setup-go@v5 180 | with: 181 | go-version: ${{ matrix.go-version }} 182 | - name: Checkout code 183 | uses: actions/checkout@v4 184 | - name: Run tests with coverage 185 | run: go test -shuffle=on -v -race -coverprofile=coverage -covermode=atomic ./sqlite 186 | -------------------------------------------------------------------------------- /sqlite/sqlite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package sqlite 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "database/sql" 11 | "fmt" 12 | "net/http" 13 | "net/http/httptest" 14 | "os" 15 | "path/filepath" 16 | "testing" 17 | "time" 18 | 19 | "github.com/flamego/flamego" 20 | "github.com/stretchr/testify/assert" 21 | "github.com/stretchr/testify/require" 22 | 23 | "github.com/flamego/session" 24 | ) 25 | 26 | func newTestDB(t *testing.T, ctx context.Context) (testDB *sql.DB, cleanup func() error) { 27 | dbname := filepath.Join(os.TempDir(), fmt.Sprintf("flamego-test-sessions-%d.db", time.Now().Unix())) 28 | testDB, err := sql.Open("sqlite", dbname) 29 | if err != nil { 30 | t.Fatalf("Failed to open test database: %v", err) 31 | } 32 | 33 | t.Cleanup(func() { 34 | defer func() { _ = testDB.Close() }() 35 | 36 | if t.Failed() { 37 | t.Logf("DATABASE %s left intact for inspection", dbname) 38 | return 39 | } 40 | 41 | err := testDB.Close() 42 | if err != nil { 43 | t.Fatalf("Failed to close test connection: %v", err) 44 | } 45 | err = os.Remove(dbname) 46 | if err != nil { 47 | t.Fatalf("Failed to delete test database: %v", err) 48 | } 49 | }) 50 | return testDB, func() error { 51 | if t.Failed() { 52 | return nil 53 | } 54 | 55 | _, err = testDB.ExecContext(ctx, `DELETE FROM sessions`) 56 | if err != nil { 57 | return err 58 | } 59 | return nil 60 | } 61 | } 62 | 63 | func TestSQLiteStore(t *testing.T) { 64 | ctx := context.Background() 65 | db, cleanup := newTestDB(t, ctx) 66 | t.Cleanup(func() { 67 | assert.Nil(t, cleanup()) 68 | }) 69 | 70 | f := flamego.NewWithLogger(&bytes.Buffer{}) 71 | f.Use(session.Sessioner( 72 | session.Options{ 73 | Initer: Initer(), 74 | Config: Config{ 75 | nowFunc: time.Now, 76 | db: db, 77 | InitTable: true, 78 | }, 79 | }, 80 | )) 81 | 82 | f.Get("/set", func(s session.Session) { 83 | s.Set("username", "flamego") 84 | }) 85 | f.Get("/get", func(s session.Session) { 86 | sid := s.ID() 87 | assert.Len(t, sid, 16) 88 | 89 | username, ok := s.Get("username").(string) 90 | assert.True(t, ok) 91 | assert.Equal(t, "flamego", username) 92 | 93 | s.Delete("username") 94 | _, ok = s.Get("username").(string) 95 | assert.False(t, ok) 96 | 97 | s.Set("random", "value") 98 | s.Flush() 99 | _, ok = s.Get("random").(string) 100 | assert.False(t, ok) 101 | }) 102 | f.Get("/destroy", func(c flamego.Context, session session.Session, store session.Store) error { 103 | return store.Destroy(c.Request().Context(), session.ID()) 104 | }) 105 | 106 | resp := httptest.NewRecorder() 107 | req, err := http.NewRequest("GET", "/set", nil) 108 | require.Nil(t, err) 109 | 110 | f.ServeHTTP(resp, req) 111 | assert.Equal(t, http.StatusOK, resp.Code) 112 | 113 | cookie := resp.Header().Get("Set-Cookie") 114 | 115 | resp = httptest.NewRecorder() 116 | req, err = http.NewRequest("GET", "/get", nil) 117 | require.Nil(t, err) 118 | 119 | req.Header.Set("Cookie", cookie) 120 | f.ServeHTTP(resp, req) 121 | assert.Equal(t, http.StatusOK, resp.Code) 122 | 123 | resp = httptest.NewRecorder() 124 | req, err = http.NewRequest("GET", "/destroy", nil) 125 | require.Nil(t, err) 126 | 127 | req.Header.Set("Cookie", cookie) 128 | f.ServeHTTP(resp, req) 129 | assert.Equal(t, http.StatusOK, resp.Code) 130 | } 131 | 132 | func TestSQLiteStore_GC(t *testing.T) { 133 | ctx := context.Background() 134 | db, cleanup := newTestDB(t, ctx) 135 | t.Cleanup(func() { 136 | assert.Nil(t, cleanup()) 137 | }) 138 | 139 | now := time.Now() 140 | store, err := Initer()(ctx, 141 | Config{ 142 | nowFunc: func() time.Time { return now }, 143 | db: db, 144 | Lifetime: time.Second, 145 | InitTable: true, 146 | }, 147 | session.IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 148 | ) 149 | require.Nil(t, err) 150 | 151 | now = now.Add(3 * time.Second) 152 | sess1, err := store.Read(ctx, "1") 153 | require.Nil(t, err) 154 | err = store.Save(ctx, sess1) 155 | require.Nil(t, err) 156 | now = now.Add(-3 * time.Second) 157 | 158 | now = now.Add(-2 * time.Second) 159 | sess2, err := store.Read(ctx, "2") 160 | require.Nil(t, err) 161 | 162 | sess2.Set("name", "flamego") 163 | err = store.Save(ctx, sess2) 164 | require.Nil(t, err) 165 | 166 | // Read on an expired session should wipe data but preserve the record. 167 | now = now.Add(2 * time.Second) 168 | tmp, err := store.Read(ctx, "2") 169 | require.Nil(t, err) 170 | assert.Nil(t, tmp.Get("name")) 171 | 172 | now = now.Add(-2 * time.Second) 173 | sess3, err := store.Read(ctx, "3") 174 | require.Nil(t, err) 175 | err = store.Save(ctx, sess3) 176 | require.Nil(t, err) 177 | 178 | now = now.Add(3 * time.Second) 179 | err = store.GC(ctx) // sess3 should be recycled 180 | require.Nil(t, err) 181 | 182 | assert.True(t, store.Exist(ctx, "1")) 183 | assert.False(t, store.Exist(ctx, "2")) 184 | assert.False(t, store.Exist(ctx, "3")) 185 | } 186 | 187 | func TestSQLiteStore_Touch(t *testing.T) { 188 | ctx := context.Background() 189 | db, cleanup := newTestDB(t, ctx) 190 | t.Cleanup(func() { 191 | assert.Nil(t, cleanup()) 192 | }) 193 | 194 | now := time.Now() 195 | store, err := Initer()(ctx, 196 | Config{ 197 | nowFunc: func() time.Time { return now }, 198 | db: db, 199 | Lifetime: time.Second, 200 | InitTable: true, 201 | }, 202 | session.IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 203 | ) 204 | require.Nil(t, err) 205 | 206 | sess, err := store.Read(ctx, "1") 207 | require.Nil(t, err) 208 | err = store.Save(ctx, sess) 209 | require.Nil(t, err) 210 | 211 | now = now.Add(2 * time.Second) 212 | // Touch should keep the session alive 213 | err = store.Touch(ctx, sess.ID()) 214 | require.Nil(t, err) 215 | 216 | err = store.GC(ctx) 217 | require.Nil(t, err) 218 | assert.True(t, store.Exist(ctx, sess.ID())) 219 | } 220 | -------------------------------------------------------------------------------- /mongo/mongo_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package mongo 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "net/http" 11 | "net/http/httptest" 12 | "os" 13 | "testing" 14 | "time" 15 | 16 | "github.com/flamego/flamego" 17 | "github.com/stretchr/testify/assert" 18 | "github.com/stretchr/testify/require" 19 | "go.mongodb.org/mongo-driver/mongo" 20 | "go.mongodb.org/mongo-driver/mongo/options" 21 | 22 | "github.com/flamego/session" 23 | ) 24 | 25 | func newTestDB(t *testing.T, ctx context.Context) (testDB *mongo.Database, cleanup func() error) { 26 | uri := os.Getenv("MONGODB_URI") 27 | client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) 28 | if err != nil { 29 | t.Fatalf("Failed to connect to mongo: %v", err) 30 | } 31 | 32 | dbname := "flamego-test-sessions" 33 | err = client.Database(dbname).Drop(ctx) 34 | if err != nil { 35 | t.Fatalf("Failed to drop test database: %v", err) 36 | } 37 | db := client.Database(dbname) 38 | t.Cleanup(func() { 39 | if t.Failed() { 40 | t.Logf("DATABASE %s left intact for inspection", dbname) 41 | return 42 | } 43 | 44 | err = db.Drop(ctx) 45 | if err != nil { 46 | t.Fatalf("Failed to drop test database: %v", err) 47 | } 48 | }) 49 | return db, func() error { 50 | if t.Failed() { 51 | return nil 52 | } 53 | 54 | err = db.Collection("sessions").Drop(ctx) 55 | if err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | } 61 | 62 | func TestMongoStore(t *testing.T) { 63 | ctx := context.Background() 64 | db, cleanup := newTestDB(t, ctx) 65 | t.Cleanup(func() { 66 | assert.NoError(t, cleanup()) 67 | }) 68 | 69 | f := flamego.NewWithLogger(&bytes.Buffer{}) 70 | f.Use(session.Sessioner( 71 | session.Options{ 72 | Initer: Initer(), 73 | Config: Config{ 74 | nowFunc: time.Now, 75 | db: db, 76 | }, 77 | }, 78 | )) 79 | 80 | f.Get("/set", func(s session.Session) { 81 | s.Set("username", "flamego") 82 | }) 83 | f.Get("/get", func(s session.Session) { 84 | sid := s.ID() 85 | assert.Len(t, sid, 16) 86 | 87 | username, ok := s.Get("username").(string) 88 | assert.True(t, ok) 89 | assert.Equal(t, "flamego", username) 90 | 91 | s.Delete("username") 92 | _, ok = s.Get("username").(string) 93 | assert.False(t, ok) 94 | 95 | s.Set("random", "value") 96 | s.Flush() 97 | _, ok = s.Get("random").(string) 98 | assert.False(t, ok) 99 | }) 100 | f.Get("/destroy", func(c flamego.Context, session session.Session, store session.Store) error { 101 | return store.Destroy(c.Request().Context(), session.ID()) 102 | }) 103 | 104 | resp := httptest.NewRecorder() 105 | req, err := http.NewRequest(http.MethodGet, "/set", nil) 106 | assert.NoError(t, err) 107 | 108 | f.ServeHTTP(resp, req) 109 | assert.Equal(t, http.StatusOK, resp.Code) 110 | 111 | cookie := resp.Header().Get("Set-Cookie") 112 | 113 | resp = httptest.NewRecorder() 114 | req, err = http.NewRequest(http.MethodGet, "/get", nil) 115 | assert.NoError(t, err) 116 | 117 | req.Header.Set("Cookie", cookie) 118 | f.ServeHTTP(resp, req) 119 | assert.Equal(t, http.StatusOK, resp.Code) 120 | 121 | resp = httptest.NewRecorder() 122 | req, err = http.NewRequest(http.MethodGet, "/destroy", nil) 123 | assert.NoError(t, err) 124 | 125 | req.Header.Set("Cookie", cookie) 126 | f.ServeHTTP(resp, req) 127 | assert.Equal(t, http.StatusOK, resp.Code) 128 | } 129 | 130 | func TestMongoStore_GC(t *testing.T) { 131 | ctx := context.Background() 132 | db, cleanup := newTestDB(t, ctx) 133 | t.Cleanup(func() { 134 | assert.Nil(t, cleanup()) 135 | }) 136 | 137 | now := time.Now() 138 | store, err := Initer()(ctx, 139 | Config{ 140 | nowFunc: func() time.Time { return now }, 141 | db: db, 142 | Lifetime: time.Second, 143 | }, 144 | session.IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 145 | ) 146 | assert.NoError(t, err) 147 | 148 | sess1, err := store.Read(ctx, "1") 149 | assert.NoError(t, err) 150 | err = store.Save(ctx, sess1) 151 | assert.NoError(t, err) 152 | 153 | now = now.Add(-2 * time.Second) 154 | sess2, err := store.Read(ctx, "2") 155 | assert.NoError(t, err) 156 | 157 | sess2.Set("name", "flamego") 158 | err = store.Save(ctx, sess2) 159 | assert.NoError(t, err) 160 | 161 | // Read on an expired session should wipe data but preserve the record 162 | now = now.Add(2 * time.Second) 163 | tmp, err := store.Read(ctx, "2") 164 | assert.NoError(t, err) 165 | assert.Nil(t, tmp.Get("name")) 166 | 167 | now = now.Add(-2 * time.Second) 168 | sess3, err := store.Read(ctx, "3") 169 | assert.NoError(t, err) 170 | err = store.Save(ctx, sess3) 171 | assert.NoError(t, err) 172 | 173 | now = now.Add(2 * time.Second) 174 | err = store.GC(ctx) // sess3 should be recycled 175 | assert.NoError(t, err) 176 | 177 | assert.True(t, store.Exist(ctx, "1")) 178 | assert.False(t, store.Exist(ctx, "2")) 179 | assert.False(t, store.Exist(ctx, "3")) 180 | } 181 | 182 | func TestMongoStore_Touch(t *testing.T) { 183 | ctx := context.Background() 184 | db, cleanup := newTestDB(t, ctx) 185 | t.Cleanup(func() { 186 | assert.Nil(t, cleanup()) 187 | }) 188 | 189 | now := time.Now() 190 | store, err := Initer()(ctx, 191 | Config{ 192 | nowFunc: func() time.Time { return now }, 193 | db: db, 194 | Lifetime: time.Second, 195 | }, 196 | session.IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 197 | ) 198 | require.Nil(t, err) 199 | 200 | sess, err := store.Read(ctx, "1") 201 | require.Nil(t, err) 202 | sess.Set("name", "flamego") 203 | err = store.Save(ctx, sess) 204 | require.Nil(t, err) 205 | 206 | now = now.Add(2 * time.Second) 207 | // Touch should keep the session alive 208 | err = store.Touch(ctx, sess.ID()) 209 | require.Nil(t, err) 210 | 211 | err = store.GC(ctx) 212 | require.Nil(t, err) 213 | assert.True(t, store.Exist(ctx, sess.ID())) 214 | 215 | // Make sure value is not wiped 216 | sess, err = store.Read(ctx, sess.ID()) 217 | require.NoError(t, err) 218 | assert.Equal(t, "flamego", sess.Get("name")) 219 | } 220 | -------------------------------------------------------------------------------- /session_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package session 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "fmt" 11 | "net/http" 12 | "net/http/httptest" 13 | "testing" 14 | 15 | "github.com/pkg/errors" 16 | "github.com/stretchr/testify/assert" 17 | "github.com/stretchr/testify/require" 18 | 19 | "github.com/flamego/flamego" 20 | ) 21 | 22 | func TestSessioner(t *testing.T) { 23 | f := flamego.NewWithLogger(&bytes.Buffer{}) 24 | f.Use(Sessioner()) 25 | f.Get("/", func(c flamego.Context, session Session, store Store) string { 26 | _ = store.GC(c.Request().Context()) 27 | return session.ID() 28 | }) 29 | f.Get("/regenerate", func(w http.ResponseWriter, r *http.Request, session Session) string { 30 | err := session.RegenerateID(w, r) 31 | require.NoError(t, err) 32 | return "something in the response body" 33 | }) 34 | 35 | resp := httptest.NewRecorder() 36 | req, err := http.NewRequest(http.MethodGet, "/", nil) 37 | require.NoError(t, err) 38 | 39 | f.ServeHTTP(resp, req) 40 | 41 | want := fmt.Sprintf("flamego_session=%s; Path=/; HttpOnly; SameSite=Lax", resp.Body.String()) 42 | cookie := resp.Header().Get("Set-Cookie") 43 | assert.Equal(t, want, cookie) 44 | 45 | // Make a request again using the same session ID 46 | resp = httptest.NewRecorder() 47 | req, err = http.NewRequest(http.MethodGet, "/", nil) 48 | require.NoError(t, err) 49 | 50 | req.Header.Set("Cookie", cookie) 51 | f.ServeHTTP(resp, req) 52 | 53 | got := fmt.Sprintf("flamego_session=%s; Path=/; HttpOnly; SameSite=Lax", resp.Body.String()) 54 | assert.Equal(t, cookie, got) 55 | 56 | // Force-regenerate the session ID even if the session ID exists. 57 | resp = httptest.NewRecorder() 58 | req, err = http.NewRequest(http.MethodGet, "/regenerate", nil) 59 | require.NoError(t, err) 60 | 61 | req.Header.Set("Cookie", cookie) 62 | f.ServeHTTP(resp, req) 63 | 64 | got = resp.Header().Get("Set-Cookie") 65 | assert.NotEmpty(t, got) 66 | assert.NotEqual(t, cookie, got) 67 | } 68 | 69 | func TestSessioner_Header(t *testing.T) { 70 | f := flamego.NewWithLogger(&bytes.Buffer{}) 71 | f.Use(Sessioner( 72 | Options{ 73 | ReadIDFunc: func(r *http.Request) string { 74 | return r.Header.Get("Session-Id") 75 | }, 76 | WriteIDFunc: func(w http.ResponseWriter, r *http.Request, sid string, created bool) { 77 | if created { 78 | r.Header.Set("Session-Id", sid) 79 | } 80 | w.Header().Set("Session-Id", sid) 81 | }, 82 | }, 83 | )) 84 | f.Get("/", func(c flamego.Context, session Session, store Store) string { 85 | _ = store.GC(c.Request().Context()) 86 | return session.ID() 87 | }) 88 | 89 | resp := httptest.NewRecorder() 90 | req, err := http.NewRequest(http.MethodGet, "/", nil) 91 | require.NoError(t, err) 92 | 93 | f.ServeHTTP(resp, req) 94 | 95 | sid := resp.Header().Get("Session-Id") 96 | assert.Equal(t, resp.Body.String(), sid) 97 | 98 | // Make a request again using the same session ID 99 | resp = httptest.NewRecorder() 100 | req, err = http.NewRequest(http.MethodGet, "/", nil) 101 | require.NoError(t, err) 102 | 103 | req.Header.Set("Session-Id", sid) 104 | f.ServeHTTP(resp, req) 105 | 106 | assert.Equal(t, sid, resp.Body.String()) 107 | } 108 | 109 | type noopStore struct{} 110 | 111 | func (s *noopStore) Exist(context.Context, string) bool { 112 | return false 113 | } 114 | 115 | func (s *noopStore) Read(_ context.Context, sid string) (Session, error) { 116 | return newMemorySession(sid, nil), nil 117 | } 118 | 119 | func (s *noopStore) Destroy(context.Context, string) error { 120 | return nil 121 | } 122 | 123 | func (s *noopStore) Touch(context.Context, string) error { 124 | return nil 125 | } 126 | 127 | func (s *noopStore) Save(ctx context.Context, _ Session) error { 128 | if ctx.Err() != nil { 129 | return errors.Wrap(ctx.Err(), "something went wrong") 130 | } 131 | return nil 132 | } 133 | 134 | func (s *noopStore) GC(context.Context) error { 135 | return nil 136 | } 137 | 138 | func TestSessioner_ContextCancel(t *testing.T) { 139 | ctx, cancel := context.WithCancel(context.Background()) 140 | 141 | f := flamego.NewWithLogger(&bytes.Buffer{}) 142 | f.Use(Sessioner( 143 | Options{ 144 | Initer: func(context.Context, ...interface{}) (Store, error) { 145 | return &noopStore{}, nil 146 | }, 147 | }, 148 | )) 149 | f.Get("/", func() { 150 | cancel() 151 | }) 152 | 153 | resp := httptest.NewRecorder() 154 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil) 155 | require.NoError(t, err) 156 | 157 | f.ServeHTTP(resp, req) 158 | } 159 | 160 | func TestSession_Flash(t *testing.T) { 161 | f := flamego.NewWithLogger(&bytes.Buffer{}) 162 | f.Use(Sessioner()) 163 | f.Get("/", func(c flamego.Context, f Flash) string { 164 | s, ok := f.(string) 165 | if !ok { 166 | return "no flash" 167 | } 168 | return s 169 | }) 170 | f.Post("/set-flash", func(s Session) { 171 | s.SetFlash("This is a flash message") 172 | }) 173 | 174 | // No flash in the initial request 175 | resp := httptest.NewRecorder() 176 | req, err := http.NewRequest(http.MethodGet, "/", nil) 177 | require.NoError(t, err) 178 | 179 | f.ServeHTTP(resp, req) 180 | 181 | assert.Equal(t, "no flash", resp.Body.String()) 182 | 183 | cookie := resp.Header().Get("Set-Cookie") 184 | 185 | // Send a request to set flash 186 | resp = httptest.NewRecorder() 187 | req, err = http.NewRequest(http.MethodPost, "/set-flash", nil) 188 | require.NoError(t, err) 189 | 190 | req.Header.Set("Cookie", cookie) 191 | f.ServeHTTP(resp, req) 192 | 193 | // Flash should be returned 194 | resp = httptest.NewRecorder() 195 | req, err = http.NewRequest(http.MethodGet, "/", nil) 196 | require.NoError(t, err) 197 | 198 | req.Header.Set("Cookie", cookie) 199 | f.ServeHTTP(resp, req) 200 | 201 | assert.Equal(t, "This is a flash message", resp.Body.String()) 202 | 203 | // Flash has gone now if we try again 204 | resp = httptest.NewRecorder() 205 | req, err = http.NewRequest(http.MethodGet, "/", nil) 206 | require.NoError(t, err) 207 | 208 | req.Header.Set("Cookie", cookie) 209 | f.ServeHTTP(resp, req) 210 | 211 | assert.Equal(t, "no flash", resp.Body.String()) 212 | } 213 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package session 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "io/fs" 11 | "os" 12 | "path/filepath" 13 | "time" 14 | 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | var _ Store = (*fileStore)(nil) 19 | 20 | // fileStore is a file implementation of the session store. 21 | type fileStore struct { 22 | nowFunc func() time.Time // The function to return the current time 23 | lifetime time.Duration // The duration to have no access to a session before being recycled 24 | rootDir string // The root directory of file session items stored on the local file system 25 | 26 | encoder Encoder 27 | decoder Decoder 28 | idWriter IDWriter 29 | } 30 | 31 | // newFileStore returns a new file session store based on given configuration. 32 | func newFileStore(cfg FileConfig, idWriter IDWriter) *fileStore { 33 | return &fileStore{ 34 | nowFunc: cfg.nowFunc, 35 | lifetime: cfg.Lifetime, 36 | rootDir: cfg.RootDir, 37 | encoder: cfg.Encoder, 38 | decoder: cfg.Decoder, 39 | idWriter: idWriter, 40 | } 41 | } 42 | 43 | // filename returns the computed file name with given sid. 44 | func (s *fileStore) filename(sid string) string { 45 | return filepath.Join(s.rootDir, string(sid[0]), string(sid[1]), sid) 46 | } 47 | 48 | // isFile returns true if given path exists as a file (i.e. not a directory). 49 | func isFile(path string) bool { 50 | f, e := os.Stat(path) 51 | if e != nil { 52 | return false 53 | } 54 | return !f.IsDir() 55 | } 56 | 57 | func (s *fileStore) Exist(_ context.Context, sid string) bool { 58 | if len(sid) < minimumSIDLength { 59 | return false 60 | } 61 | return isFile(s.filename(sid)) 62 | } 63 | 64 | func (s *fileStore) Read(_ context.Context, sid string) (Session, error) { 65 | if len(sid) < minimumSIDLength { 66 | return nil, ErrMinimumSIDLength 67 | } 68 | 69 | filename := s.filename(sid) 70 | if !isFile(filename) { 71 | err := os.MkdirAll(filepath.Dir(filename), 0700) 72 | if err != nil { 73 | return nil, errors.Wrap(err, "create parent directory") 74 | } 75 | 76 | return NewBaseSession(sid, s.encoder, s.idWriter), nil 77 | } 78 | 79 | // Discard existing data if it's expired 80 | fi, err := os.Stat(filename) 81 | if err != nil { 82 | return nil, errors.Wrap(err, "stat file") 83 | } 84 | if !fi.ModTime().Add(s.lifetime).After(s.nowFunc()) { 85 | return NewBaseSession(sid, s.encoder, s.idWriter), nil 86 | } 87 | 88 | binary, err := os.ReadFile(filename) 89 | if err != nil { 90 | return nil, errors.Wrap(err, "read file") 91 | } 92 | 93 | data, err := s.decoder(binary) 94 | if err != nil { 95 | return nil, errors.Wrap(err, "decode") 96 | } 97 | return NewBaseSessionWithData(sid, s.encoder, s.idWriter, data), nil 98 | } 99 | 100 | func (s *fileStore) Destroy(_ context.Context, sid string) error { 101 | if len(sid) < minimumSIDLength { 102 | return nil 103 | } 104 | return os.Remove(s.filename(sid)) 105 | } 106 | 107 | func (s *fileStore) Touch(_ context.Context, sid string) error { 108 | filename := s.filename(sid) 109 | if !isFile(filename) { 110 | return nil 111 | } 112 | 113 | err := os.Chtimes(filename, s.nowFunc(), s.nowFunc()) 114 | if err != nil { 115 | return errors.Wrap(err, "change times") 116 | } 117 | return nil 118 | } 119 | 120 | func (s *fileStore) Save(_ context.Context, sess Session) error { 121 | if len(sess.ID()) < minimumSIDLength { 122 | return ErrMinimumSIDLength 123 | } 124 | 125 | binary, err := sess.Encode() 126 | if err != nil { 127 | return errors.Wrap(err, "encode") 128 | } 129 | 130 | filename := s.filename(sess.ID()) 131 | err = os.WriteFile(filename, binary, 0600) 132 | if err != nil { 133 | return errors.Wrap(err, "write file") 134 | } 135 | 136 | err = os.Chtimes(filename, s.nowFunc(), s.nowFunc()) 137 | if err != nil { 138 | return errors.Wrap(err, "change times") 139 | } 140 | return nil 141 | } 142 | 143 | func (s *fileStore) GC(ctx context.Context) error { 144 | err := filepath.WalkDir(s.rootDir, func(path string, d fs.DirEntry, err error) error { 145 | select { 146 | case <-ctx.Done(): 147 | return ctx.Err() 148 | default: 149 | } 150 | 151 | if err != nil { 152 | return err 153 | } 154 | if d.IsDir() { 155 | return nil 156 | } 157 | 158 | fi, err := d.Info() 159 | if err != nil { 160 | return err 161 | } 162 | if fi.ModTime().Add(s.lifetime).After(s.nowFunc()) { 163 | return nil 164 | } 165 | return os.Remove(path) 166 | }) 167 | if err != nil && !errors.Is(err, ctx.Err()) { 168 | return err 169 | } 170 | return nil 171 | } 172 | 173 | // FileConfig contains options for the file session store. 174 | type FileConfig struct { 175 | // For tests only. 176 | nowFunc func() time.Time 177 | 178 | // Lifetime is the duration to have no access to a session before being 179 | // recycled. Default is 3600 seconds. 180 | Lifetime time.Duration 181 | // RootDir is the root directory of file session items stored on the local file 182 | // system. Default is "sessions". 183 | RootDir string 184 | // Encoder is the encoder to encode session data. Default is GobEncoder. 185 | Encoder Encoder 186 | // Decoder is the decoder to decode session data. Default is GobDecoder. 187 | Decoder Decoder 188 | } 189 | 190 | // FileIniter returns the Initer for the file session store. 191 | func FileIniter() Initer { 192 | return func(ctx context.Context, args ...interface{}) (Store, error) { 193 | var cfg *FileConfig 194 | var idWriter IDWriter 195 | for i := range args { 196 | switch v := args[i].(type) { 197 | case FileConfig: 198 | cfg = &v 199 | case IDWriter: 200 | idWriter = v 201 | } 202 | } 203 | if idWriter == nil { 204 | return nil, errors.New("IDWriter not given") 205 | } 206 | 207 | if cfg == nil { 208 | return nil, fmt.Errorf("config object with the type '%T' not found", FileConfig{}) 209 | } 210 | if cfg.nowFunc == nil { 211 | cfg.nowFunc = time.Now 212 | } 213 | if cfg.Lifetime.Seconds() < 1 { 214 | cfg.Lifetime = 3600 * time.Second 215 | } 216 | if cfg.RootDir == "" { 217 | cfg.RootDir = "sessions" 218 | } 219 | if cfg.Encoder == nil { 220 | cfg.Encoder = GobEncoder 221 | } 222 | if cfg.Decoder == nil { 223 | cfg.Decoder = GobDecoder 224 | } 225 | 226 | return newFileStore(*cfg, idWriter), nil 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package sqlite 6 | 7 | import ( 8 | "context" 9 | "database/sql" 10 | "fmt" 11 | "time" 12 | 13 | "github.com/pkg/errors" 14 | _ "modernc.org/sqlite" 15 | 16 | "github.com/flamego/session" 17 | ) 18 | 19 | var _ session.Store = (*sqliteStore)(nil) 20 | 21 | // sqliteStore is a SQLite implementation of the session store. 22 | type sqliteStore struct { 23 | nowFunc func() time.Time // The function to return the current time 24 | lifetime time.Duration // The duration to have access to a session before being recycled 25 | db *sql.DB // The database connection 26 | table string // The database table for storing session data 27 | 28 | encoder session.Encoder 29 | decoder session.Decoder 30 | idWriter session.IDWriter 31 | } 32 | 33 | // newSQLiteStore returns a new SQLite session store based on given 34 | // configuration. 35 | func newSQLiteStore(cfg Config, idWriter session.IDWriter) *sqliteStore { 36 | return &sqliteStore{ 37 | nowFunc: cfg.nowFunc, 38 | lifetime: cfg.Lifetime, 39 | db: cfg.db, 40 | table: cfg.Table, 41 | encoder: cfg.Encoder, 42 | decoder: cfg.Decoder, 43 | idWriter: idWriter, 44 | } 45 | } 46 | 47 | func (s *sqliteStore) Exist(ctx context.Context, sid string) bool { 48 | var exists bool 49 | q := fmt.Sprintf(`SELECT EXISTS (SELECT 1 FROM %q WHERE key = $1)`, s.table) 50 | err := s.db.QueryRowContext(ctx, q, sid).Scan(&exists) 51 | return err == nil && exists 52 | } 53 | 54 | func (s *sqliteStore) Read(ctx context.Context, sid string) (session.Session, error) { 55 | var binary []byte 56 | var expiredAtStr string 57 | q := fmt.Sprintf(`SELECT data, expired_at FROM %q WHERE key = $1`, s.table) 58 | err := s.db.QueryRowContext(ctx, q, sid).Scan(&binary, &expiredAtStr) 59 | if err == nil { 60 | expiredAt, _ := time.Parse(time.DateTime, expiredAtStr) 61 | // Discard existing data if it's expired 62 | if !s.nowFunc().Before(expiredAt.Add(s.lifetime)) { 63 | return session.NewBaseSession(sid, s.encoder, s.idWriter), nil 64 | } 65 | 66 | data, err := s.decoder(binary) 67 | if err != nil { 68 | return nil, errors.Wrap(err, "decode") 69 | } 70 | return session.NewBaseSessionWithData(sid, s.encoder, s.idWriter, data), nil 71 | } else if err != sql.ErrNoRows { 72 | return nil, errors.Wrap(err, "select") 73 | } 74 | 75 | return session.NewBaseSession(sid, s.encoder, s.idWriter), nil 76 | } 77 | 78 | func (s *sqliteStore) Destroy(ctx context.Context, sid string) error { 79 | q := fmt.Sprintf(`DELETE FROM %q WHERE key = $1`, s.table) 80 | _, err := s.db.ExecContext(ctx, q, sid) 81 | return err 82 | } 83 | 84 | func (s *sqliteStore) Touch(ctx context.Context, sid string) error { 85 | q := fmt.Sprintf(`UPDATE %q SET expired_at = $1 WHERE key = $2`, s.table) 86 | _, err := s.db.ExecContext(ctx, q, s.nowFunc().Add(s.lifetime).UTC().Format(time.DateTime), sid) 87 | if err != nil { 88 | return errors.Wrap(err, "update") 89 | } 90 | return nil 91 | } 92 | 93 | func (s *sqliteStore) Save(ctx context.Context, sess session.Session) error { 94 | binary, err := sess.Encode() 95 | if err != nil { 96 | return errors.Wrap(err, "encode") 97 | } 98 | 99 | q := fmt.Sprintf(` 100 | INSERT INTO %q (key, data, expired_at) 101 | VALUES ($1, $2, $3) 102 | ON CONFLICT (key) 103 | DO UPDATE SET 104 | data = excluded.data, 105 | expired_at = excluded.expired_at 106 | `, s.table) 107 | _, err = s.db.ExecContext(ctx, q, sess.ID(), binary, s.nowFunc().Add(s.lifetime).UTC().Format(time.DateTime)) 108 | if err != nil { 109 | return errors.Wrap(err, "upsert") 110 | } 111 | return nil 112 | } 113 | 114 | func (s *sqliteStore) GC(ctx context.Context) error { 115 | q := fmt.Sprintf(`DELETE FROM %q WHERE datetime(expired_at) <= datetime($1)`, s.table) 116 | _, err := s.db.ExecContext(ctx, q, s.nowFunc().UTC().Format(time.DateTime)) 117 | return err 118 | } 119 | 120 | // Config contains options for the SQLite session store. 121 | type Config struct { 122 | // For tests only 123 | nowFunc func() time.Time 124 | db *sql.DB 125 | 126 | // Lifetime is the duration to have no access to a session before being 127 | // recycled. Default is 3600 seconds. 128 | Lifetime time.Duration 129 | // DSN is the database source name to the SQLite. 130 | DSN string 131 | // Table is the table name for storing session data. Default is "sessions". 132 | Table string 133 | // Encoder is the encoder to encode session data. Default is session.GobEncoder. 134 | Encoder session.Encoder 135 | // Decoder is the decoder to decode session data. Default is session.GobDecoder. 136 | Decoder session.Decoder 137 | // InitTable indicates whether to create a default session table when not exists automatically. 138 | InitTable bool 139 | } 140 | 141 | // Initer returns the session.Initer for the SQLite session store. 142 | func Initer() session.Initer { 143 | return func(ctx context.Context, args ...interface{}) (session.Store, error) { 144 | var cfg *Config 145 | var idWriter session.IDWriter 146 | for i := range args { 147 | switch v := args[i].(type) { 148 | case Config: 149 | cfg = &v 150 | case session.IDWriter: 151 | idWriter = v 152 | } 153 | } 154 | if idWriter == nil { 155 | return nil, errors.New("IDWriter not given") 156 | } 157 | 158 | if cfg == nil { 159 | return nil, fmt.Errorf("config object with the type '%T' not found", Config{}) 160 | } else if cfg.DSN == "" && cfg.db == nil { 161 | return nil, errors.New("empty DSN") 162 | } 163 | 164 | if cfg.db == nil { 165 | db, err := sql.Open("sqlite", cfg.DSN) 166 | if err != nil { 167 | return nil, errors.Wrap(err, "open database") 168 | } 169 | cfg.db = db 170 | } 171 | 172 | if cfg.InitTable { 173 | q := ` 174 | CREATE TABLE IF NOT EXISTS sessions ( 175 | key TEXT PRIMARY KEY, 176 | data BLOB NOT NULL, 177 | expired_at TEXT NOT NULL 178 | )` 179 | _, err := cfg.db.ExecContext(ctx, q) 180 | if err != nil { 181 | return nil, errors.Wrap(err, "create table") 182 | } 183 | } 184 | 185 | if cfg.nowFunc == nil { 186 | cfg.nowFunc = time.Now 187 | } 188 | if cfg.Lifetime.Seconds() < 1 { 189 | cfg.Lifetime = 3600 * time.Second 190 | } 191 | if cfg.Table == "" { 192 | cfg.Table = "sessions" 193 | } 194 | if cfg.Encoder == nil { 195 | cfg.Encoder = session.GobEncoder 196 | } 197 | if cfg.Decoder == nil { 198 | cfg.Decoder = session.GobDecoder 199 | } 200 | 201 | return newSQLiteStore(*cfg, idWriter), nil 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /postgres/postgres.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package postgres 6 | 7 | import ( 8 | "context" 9 | "database/sql" 10 | "fmt" 11 | "time" 12 | 13 | "github.com/jackc/pgx/v5" 14 | "github.com/jackc/pgx/v5/stdlib" 15 | "github.com/pkg/errors" 16 | 17 | "github.com/flamego/session" 18 | ) 19 | 20 | var _ session.Store = (*postgresStore)(nil) 21 | 22 | // postgresStore is a Postgres implementation of the session store. 23 | type postgresStore struct { 24 | nowFunc func() time.Time // The function to return the current time 25 | lifetime time.Duration // The duration to have access to a session before being recycled 26 | db *sql.DB // The database connection 27 | table string // The database table for storing session data 28 | 29 | encoder session.Encoder 30 | decoder session.Decoder 31 | idWriter session.IDWriter 32 | } 33 | 34 | // newPostgresStore returns a new Postgres session store based on given 35 | // configuration. 36 | func newPostgresStore(cfg Config, idWriter session.IDWriter) *postgresStore { 37 | return &postgresStore{ 38 | nowFunc: cfg.nowFunc, 39 | lifetime: cfg.Lifetime, 40 | db: cfg.db, 41 | table: cfg.Table, 42 | encoder: cfg.Encoder, 43 | decoder: cfg.Decoder, 44 | idWriter: idWriter, 45 | } 46 | } 47 | 48 | func (s *postgresStore) Exist(ctx context.Context, sid string) bool { 49 | var exists bool 50 | q := fmt.Sprintf(`SELECT EXISTS (SELECT FROM %q WHERE key = $1)`, s.table) 51 | err := s.db.QueryRowContext(ctx, q, sid).Scan(&exists) 52 | return err == nil && exists 53 | } 54 | 55 | func (s *postgresStore) Read(ctx context.Context, sid string) (session.Session, error) { 56 | var binary []byte 57 | var expiredAt time.Time 58 | q := fmt.Sprintf(`SELECT data, expired_at FROM %q WHERE key = $1`, s.table) 59 | err := s.db.QueryRowContext(ctx, q, sid).Scan(&binary, &expiredAt) 60 | if err == nil { 61 | // Discard existing data if it's expired 62 | if !s.nowFunc().Before(expiredAt.Add(s.lifetime)) { 63 | return session.NewBaseSession(sid, s.encoder, s.idWriter), nil 64 | } 65 | 66 | data, err := s.decoder(binary) 67 | if err != nil { 68 | return nil, errors.Wrap(err, "decode") 69 | } 70 | return session.NewBaseSessionWithData(sid, s.encoder, s.idWriter, data), nil 71 | } else if err != sql.ErrNoRows { 72 | return nil, errors.Wrap(err, "select") 73 | } 74 | 75 | return session.NewBaseSession(sid, s.encoder, s.idWriter), nil 76 | } 77 | 78 | func (s *postgresStore) Destroy(ctx context.Context, sid string) error { 79 | q := fmt.Sprintf(`DELETE FROM %q WHERE key = $1`, s.table) 80 | _, err := s.db.ExecContext(ctx, q, sid) 81 | return err 82 | } 83 | 84 | func (s *postgresStore) Touch(ctx context.Context, sid string) error { 85 | q := fmt.Sprintf(`UPDATE %q SET expired_at = $1 WHERE key = $2`, s.table) 86 | _, err := s.db.ExecContext(ctx, q, s.nowFunc().Add(s.lifetime).UTC(), sid) 87 | if err != nil { 88 | return errors.Wrap(err, "update") 89 | } 90 | return nil 91 | } 92 | 93 | func (s *postgresStore) Save(ctx context.Context, sess session.Session) error { 94 | binary, err := sess.Encode() 95 | if err != nil { 96 | return errors.Wrap(err, "encode") 97 | } 98 | 99 | q := fmt.Sprintf(` 100 | INSERT INTO %q (key, data, expired_at) 101 | VALUES ($1, $2, $3) 102 | ON CONFLICT (key) 103 | DO UPDATE SET 104 | data = excluded.data, 105 | expired_at = excluded.expired_at 106 | `, s.table) 107 | _, err = s.db.ExecContext(ctx, q, sess.ID(), binary, s.nowFunc().Add(s.lifetime).UTC()) 108 | if err != nil { 109 | return errors.Wrap(err, "upsert") 110 | } 111 | return nil 112 | } 113 | 114 | func (s *postgresStore) GC(ctx context.Context) error { 115 | q := fmt.Sprintf(`DELETE FROM %q WHERE expired_at <= $1`, s.table) 116 | _, err := s.db.ExecContext(ctx, q, s.nowFunc().UTC()) 117 | return err 118 | } 119 | 120 | // Config contains options for the Postgres session store. 121 | type Config struct { 122 | // For tests only 123 | nowFunc func() time.Time 124 | db *sql.DB 125 | 126 | // Lifetime is the duration to have no access to a session before being 127 | // recycled. Default is 3600 seconds. 128 | Lifetime time.Duration 129 | // DSN is the database source name to the Postgres. 130 | DSN string 131 | // Table is the table name for storing session data. Default is "sessions". 132 | Table string 133 | // Encoder is the encoder to encode session data. Default is session.GobEncoder. 134 | Encoder session.Encoder 135 | // Decoder is the decoder to decode session data. Default is session.GobDecoder. 136 | Decoder session.Decoder 137 | // InitTable indicates whether to create a default session table when not exists automatically. 138 | InitTable bool 139 | } 140 | 141 | func openDB(dsn string) (*sql.DB, error) { 142 | config, err := pgx.ParseConfig(dsn) 143 | if err != nil { 144 | return nil, errors.Wrap(err, "parse config") 145 | } 146 | return stdlib.OpenDB(*config), nil 147 | } 148 | 149 | // Initer returns the session.Initer for the Postgres session store. 150 | func Initer() session.Initer { 151 | return func(ctx context.Context, args ...interface{}) (session.Store, error) { 152 | var cfg *Config 153 | var idWriter session.IDWriter 154 | for i := range args { 155 | switch v := args[i].(type) { 156 | case Config: 157 | cfg = &v 158 | case session.IDWriter: 159 | idWriter = v 160 | } 161 | } 162 | if idWriter == nil { 163 | return nil, errors.New("IDWriter not given") 164 | } 165 | 166 | if cfg == nil { 167 | return nil, fmt.Errorf("config object with the type '%T' not found", Config{}) 168 | } else if cfg.DSN == "" && cfg.db == nil { 169 | return nil, errors.New("empty DSN") 170 | } 171 | 172 | if cfg.db == nil { 173 | db, err := openDB(cfg.DSN) 174 | if err != nil { 175 | return nil, errors.Wrap(err, "open database") 176 | } 177 | cfg.db = db 178 | } 179 | 180 | if cfg.InitTable { 181 | q := ` 182 | CREATE TABLE IF NOT EXISTS sessions ( 183 | key TEXT PRIMARY KEY, 184 | data BYTEA NOT NULL, 185 | expired_at TIMESTAMP WITH TIME ZONE NOT NULL 186 | )` 187 | _, err := cfg.db.ExecContext(ctx, q) 188 | if err != nil { 189 | return nil, errors.Wrap(err, "create table") 190 | } 191 | } 192 | 193 | if cfg.nowFunc == nil { 194 | cfg.nowFunc = time.Now 195 | } 196 | if cfg.Lifetime.Seconds() < 1 { 197 | cfg.Lifetime = 3600 * time.Second 198 | } 199 | if cfg.Table == "" { 200 | cfg.Table = "sessions" 201 | } 202 | if cfg.Encoder == nil { 203 | cfg.Encoder = session.GobEncoder 204 | } 205 | if cfg.Decoder == nil { 206 | cfg.Decoder = session.GobDecoder 207 | } 208 | 209 | return newPostgresStore(*cfg, idWriter), nil 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /mongo/mongo.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package mongo 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "time" 11 | 12 | "github.com/pkg/errors" 13 | "go.mongodb.org/mongo-driver/bson" 14 | "go.mongodb.org/mongo-driver/bson/primitive" 15 | "go.mongodb.org/mongo-driver/mongo" 16 | "go.mongodb.org/mongo-driver/mongo/options" 17 | 18 | "github.com/flamego/session" 19 | ) 20 | 21 | var _ session.Store = (*mongoStore)(nil) 22 | 23 | // mongoStore is a MongoDB implementation of the session store. 24 | type mongoStore struct { 25 | nowFunc func() time.Time // The function to return the current time 26 | lifetime time.Duration // The duration to have access to a session before being recycled 27 | db *mongo.Database // The database connection 28 | collection string // The database collection for storing session data 29 | 30 | encoder session.Encoder 31 | decoder session.Decoder 32 | idWriter session.IDWriter 33 | } 34 | 35 | // newMongoStore returns a new MongoDB session store based on given configuration. 36 | func newMongoStore(cfg Config, idWriter session.IDWriter) *mongoStore { 37 | return &mongoStore{ 38 | nowFunc: cfg.nowFunc, 39 | lifetime: cfg.Lifetime, 40 | db: cfg.db, 41 | collection: cfg.Collection, 42 | encoder: cfg.Encoder, 43 | decoder: cfg.Decoder, 44 | idWriter: idWriter, 45 | } 46 | } 47 | 48 | func (s *mongoStore) Exist(ctx context.Context, sid string) bool { 49 | err := s.db.Collection(s.collection).FindOne(ctx, bson.M{"key": sid}).Err() 50 | return err == nil 51 | } 52 | 53 | func (s *mongoStore) Read(ctx context.Context, sid string) (session.Session, error) { 54 | var result bson.M 55 | err := s.db.Collection(s.collection).FindOne(ctx, bson.M{"key": sid}).Decode(&result) 56 | if err == nil { 57 | binary, ok := result["data"].(primitive.Binary) 58 | if !ok { 59 | return nil, errors.Errorf(`assert "data" key: want type primitive.Binary but got %T`, result["data"]) 60 | } 61 | 62 | expiredAt, ok := result["expired_at"].(primitive.DateTime) 63 | if !ok { 64 | return nil, errors.Errorf(`assert "expired_at" key: want type primitive.DateTime but got %T`, result["expired_at"]) 65 | } 66 | 67 | // Discard existing data if it's expired 68 | if !s.nowFunc().Before(expiredAt.Time().Add(s.lifetime)) { 69 | return session.NewBaseSession(sid, s.encoder, s.idWriter), nil 70 | } 71 | 72 | data, err := s.decoder(binary.Data) 73 | if err != nil { 74 | return nil, errors.Wrap(err, "decode") 75 | } 76 | return session.NewBaseSessionWithData(sid, s.encoder, s.idWriter, data), nil 77 | } else if err != mongo.ErrNoDocuments { 78 | return nil, errors.Wrap(err, "find") 79 | } 80 | 81 | return session.NewBaseSession(sid, s.encoder, s.idWriter), nil 82 | } 83 | 84 | func (s *mongoStore) Destroy(ctx context.Context, sid string) error { 85 | _, err := s.db.Collection(s.collection).DeleteOne(ctx, bson.M{"key": sid}) 86 | if err != nil { 87 | return errors.Wrap(err, "delete") 88 | } 89 | return nil 90 | } 91 | 92 | func (s *mongoStore) Touch(ctx context.Context, sid string) error { 93 | _, err := s.db.Collection(s.collection). 94 | UpdateOne(ctx, 95 | bson.M{"key": sid}, 96 | bson.M{"$set": bson.M{ 97 | "expired_at": s.nowFunc().Add(s.lifetime).UTC(), 98 | }}, 99 | ) 100 | if err != nil { 101 | return errors.Wrap(err, "update") 102 | } 103 | return nil 104 | } 105 | 106 | func (s *mongoStore) Save(ctx context.Context, sess session.Session) error { 107 | binary, err := sess.Encode() 108 | if err != nil { 109 | return errors.Wrap(err, "encode") 110 | } 111 | 112 | upsert := true 113 | _, err = s.db.Collection(s.collection). 114 | UpdateOne(ctx, bson.M{"key": sess.ID()}, bson.M{"$set": bson.M{ 115 | "key": sess.ID(), 116 | "data": binary, 117 | "expired_at": s.nowFunc().Add(s.lifetime).UTC(), 118 | }}, &options.UpdateOptions{ 119 | Upsert: &upsert, 120 | }) 121 | if err != nil { 122 | return errors.Wrap(err, "upsert") 123 | } 124 | return nil 125 | } 126 | 127 | func (s *mongoStore) GC(ctx context.Context) error { 128 | _, err := s.db.Collection(s.collection).DeleteMany(ctx, bson.M{"expired_at": bson.M{"$lte": s.nowFunc().UTC()}}) 129 | if err != nil { 130 | return errors.Wrap(err, "delete") 131 | } 132 | return nil 133 | } 134 | 135 | // Options keeps the settings to set up MongoDB client connection. 136 | type Options = options.ClientOptions 137 | 138 | // Config contains options for the MongoDB session store. 139 | type Config struct { 140 | // For tests only 141 | nowFunc func() time.Time 142 | db *mongo.Database 143 | 144 | // Options is the settings to set up the MongoDB client connection. 145 | Options *Options 146 | // Database is the database name of the MongoDB. 147 | Database string 148 | // Collection is the collection name for storing session data. Default is "sessions". 149 | Collection string 150 | // Lifetime is the duration to have no access to a session before being 151 | // recycled. Default is 3600 seconds. 152 | Lifetime time.Duration 153 | // Encoder is the encoder to encode session data. Default is session.GobEncoder. 154 | Encoder session.Encoder 155 | // Decoder is the decoder to decode session data. Default is session.GobDecoder. 156 | Decoder session.Decoder 157 | } 158 | 159 | // Initer returns the session.Initer for the MongoDB session store. 160 | func Initer() session.Initer { 161 | return func(ctx context.Context, args ...interface{}) (session.Store, error) { 162 | var cfg *Config 163 | var idWriter session.IDWriter 164 | for i := range args { 165 | switch v := args[i].(type) { 166 | case Config: 167 | cfg = &v 168 | case session.IDWriter: 169 | idWriter = v 170 | } 171 | } 172 | if idWriter == nil { 173 | return nil, errors.New("IDWriter not given") 174 | } 175 | 176 | if cfg == nil { 177 | return nil, fmt.Errorf("config object with the type '%T' not found", Config{}) 178 | } else if cfg.Database == "" && cfg.db == nil { 179 | return nil, errors.New("empty Database") 180 | } 181 | 182 | if cfg.db == nil { 183 | client, err := mongo.Connect(ctx, cfg.Options) 184 | if err != nil { 185 | return nil, errors.Wrap(err, "connect database") 186 | } 187 | cfg.db = client.Database(cfg.Database) 188 | } 189 | 190 | if cfg.nowFunc == nil { 191 | cfg.nowFunc = time.Now 192 | } 193 | if cfg.Lifetime.Seconds() < 1 { 194 | cfg.Lifetime = 3600 * time.Second 195 | } 196 | if cfg.Collection == "" { 197 | cfg.Collection = "sessions" 198 | } 199 | if cfg.Encoder == nil { 200 | cfg.Encoder = session.GobEncoder 201 | } 202 | if cfg.Decoder == nil { 203 | cfg.Decoder = session.GobDecoder 204 | } 205 | 206 | return newMongoStore(*cfg, idWriter), nil 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /mysql/mysql_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package mysql 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "database/sql" 11 | "fmt" 12 | "net/http" 13 | "net/http/httptest" 14 | "os" 15 | "testing" 16 | "time" 17 | 18 | "github.com/flamego/flamego" 19 | "github.com/go-sql-driver/mysql" 20 | "github.com/stretchr/testify/assert" 21 | "github.com/stretchr/testify/require" 22 | 23 | "github.com/flamego/session" 24 | ) 25 | 26 | func newTestDB(t *testing.T, ctx context.Context) (testDB *sql.DB, cleanup func() error) { 27 | dsn := os.ExpandEnv("$MYSQL_USER:$MYSQL_PASSWORD@tcp($MYSQL_HOST:$MYSQL_PORT)/?charset=utf8&parseTime=true") 28 | db, err := sql.Open("mysql", dsn) 29 | if err != nil { 30 | t.Fatalf("Failed to open database: %v", err) 31 | } 32 | 33 | dbname := "flamego-test-sessions" 34 | _, err = db.ExecContext(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", quoteWithBackticks(dbname))) 35 | if err != nil { 36 | t.Fatalf("Failed to drop test database: %v", err) 37 | } 38 | 39 | _, err = db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE %s", quoteWithBackticks(dbname))) 40 | if err != nil { 41 | t.Fatalf("Failed to create test database: %v", err) 42 | } 43 | 44 | cfg, err := mysql.ParseDSN(dsn) 45 | if err != nil { 46 | t.Fatalf("Failed to parse DSN: %v", err) 47 | } 48 | cfg.DBName = dbname 49 | 50 | testDB, err = sql.Open("mysql", cfg.FormatDSN()) 51 | if err != nil { 52 | t.Fatalf("Failed to open test database: %v", err) 53 | } 54 | 55 | t.Cleanup(func() { 56 | defer func() { _ = db.Close() }() 57 | 58 | if t.Failed() { 59 | t.Logf("DATABASE %s left intact for inspection", dbname) 60 | return 61 | } 62 | 63 | err := testDB.Close() 64 | if err != nil { 65 | t.Fatalf("Failed to close test connection: %v", err) 66 | } 67 | 68 | _, err = db.ExecContext(ctx, fmt.Sprintf(`DROP DATABASE %s`, quoteWithBackticks(dbname))) 69 | if err != nil { 70 | t.Fatalf("Failed to drop test database: %v", err) 71 | } 72 | }) 73 | return testDB, func() error { 74 | if t.Failed() { 75 | return nil 76 | } 77 | 78 | _, err = testDB.ExecContext(ctx, `TRUNCATE TABLE sessions`) 79 | if err != nil { 80 | return err 81 | } 82 | return nil 83 | } 84 | } 85 | 86 | func TestMySQLStore(t *testing.T) { 87 | ctx := context.Background() 88 | db, cleanup := newTestDB(t, ctx) 89 | t.Cleanup(func() { 90 | assert.Nil(t, cleanup()) 91 | }) 92 | 93 | f := flamego.NewWithLogger(&bytes.Buffer{}) 94 | f.Use(session.Sessioner( 95 | session.Options{ 96 | Initer: Initer(), 97 | Config: Config{ 98 | nowFunc: time.Now, 99 | db: db, 100 | InitTable: true, 101 | }, 102 | }, 103 | )) 104 | 105 | f.Get("/set", func(s session.Session) { 106 | s.Set("username", "flamego") 107 | }) 108 | f.Get("/get", func(s session.Session) { 109 | sid := s.ID() 110 | assert.Len(t, sid, 16) 111 | 112 | username, ok := s.Get("username").(string) 113 | assert.True(t, ok) 114 | assert.Equal(t, "flamego", username) 115 | 116 | s.Delete("username") 117 | _, ok = s.Get("username").(string) 118 | assert.False(t, ok) 119 | 120 | s.Set("random", "value") 121 | s.Flush() 122 | _, ok = s.Get("random").(string) 123 | assert.False(t, ok) 124 | }) 125 | f.Get("/destroy", func(c flamego.Context, session session.Session, store session.Store) error { 126 | return store.Destroy(c.Request().Context(), session.ID()) 127 | }) 128 | 129 | resp := httptest.NewRecorder() 130 | req, err := http.NewRequest("GET", "/set", nil) 131 | require.Nil(t, err) 132 | 133 | f.ServeHTTP(resp, req) 134 | assert.Equal(t, http.StatusOK, resp.Code) 135 | 136 | cookie := resp.Header().Get("Set-Cookie") 137 | 138 | resp = httptest.NewRecorder() 139 | req, err = http.NewRequest("GET", "/get", nil) 140 | require.Nil(t, err) 141 | 142 | req.Header.Set("Cookie", cookie) 143 | f.ServeHTTP(resp, req) 144 | assert.Equal(t, http.StatusOK, resp.Code) 145 | 146 | resp = httptest.NewRecorder() 147 | req, err = http.NewRequest("GET", "/destroy", nil) 148 | require.Nil(t, err) 149 | 150 | req.Header.Set("Cookie", cookie) 151 | f.ServeHTTP(resp, req) 152 | assert.Equal(t, http.StatusOK, resp.Code) 153 | } 154 | 155 | func TestMySQLStore_GC(t *testing.T) { 156 | ctx := context.Background() 157 | db, cleanup := newTestDB(t, ctx) 158 | t.Cleanup(func() { 159 | assert.Nil(t, cleanup()) 160 | }) 161 | 162 | now := time.Now() 163 | store, err := Initer()(ctx, 164 | Config{ 165 | nowFunc: func() time.Time { return now }, 166 | db: db, 167 | Lifetime: time.Second, 168 | InitTable: true, 169 | }, 170 | session.IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 171 | ) 172 | require.Nil(t, err) 173 | 174 | now = now.Add(3 * time.Second) 175 | sess1, err := store.Read(ctx, "1") 176 | require.Nil(t, err) 177 | err = store.Save(ctx, sess1) 178 | require.Nil(t, err) 179 | now = now.Add(-3 * time.Second) 180 | 181 | now = now.Add(-2 * time.Second) 182 | sess2, err := store.Read(ctx, "2") 183 | require.Nil(t, err) 184 | 185 | sess2.Set("name", "flamego") 186 | err = store.Save(ctx, sess2) 187 | require.Nil(t, err) 188 | 189 | // Read on an expired session should wipe data but preserve the record. 190 | // NOTE: MySQL is behaving flaky on exact the seconds, so let's wait one more 191 | // second. 192 | now = now.Add(3 * time.Second) 193 | tmp, err := store.Read(ctx, "2") 194 | require.Nil(t, err) 195 | assert.Nil(t, tmp.Get("name")) 196 | 197 | now = now.Add(-2 * time.Second) 198 | sess3, err := store.Read(ctx, "3") 199 | require.Nil(t, err) 200 | err = store.Save(ctx, sess3) 201 | require.Nil(t, err) 202 | 203 | now = now.Add(3 * time.Second) 204 | err = store.GC(ctx) // sess3 should be recycled 205 | require.Nil(t, err) 206 | 207 | assert.True(t, store.Exist(ctx, "1")) 208 | assert.False(t, store.Exist(ctx, "2")) 209 | assert.False(t, store.Exist(ctx, "3")) 210 | } 211 | 212 | func TestMySQLStore_Touch(t *testing.T) { 213 | ctx := context.Background() 214 | db, cleanup := newTestDB(t, ctx) 215 | t.Cleanup(func() { 216 | assert.Nil(t, cleanup()) 217 | }) 218 | 219 | now := time.Now() 220 | store, err := Initer()(ctx, 221 | Config{ 222 | nowFunc: func() time.Time { return now }, 223 | db: db, 224 | Lifetime: time.Second, 225 | InitTable: true, 226 | }, 227 | session.IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 228 | ) 229 | require.Nil(t, err) 230 | 231 | sess, err := store.Read(ctx, "1") 232 | require.Nil(t, err) 233 | err = store.Save(ctx, sess) 234 | require.Nil(t, err) 235 | 236 | now = now.Add(2 * time.Second) 237 | // Touch should keep the session alive 238 | err = store.Touch(ctx, sess.ID()) 239 | require.Nil(t, err) 240 | 241 | err = store.GC(ctx) 242 | require.Nil(t, err) 243 | assert.True(t, store.Exist(ctx, sess.ID())) 244 | } 245 | -------------------------------------------------------------------------------- /session.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package session 6 | 7 | import ( 8 | "context" 9 | "net/http" 10 | "reflect" 11 | "time" 12 | 13 | "github.com/pkg/errors" 14 | 15 | "github.com/flamego/flamego" 16 | ) 17 | 18 | // Session is a session for the current request. 19 | type Session interface { 20 | // ID returns the session ID. 21 | ID() string 22 | // RegenerateID regenerates the session ID. 23 | RegenerateID(w http.ResponseWriter, r *http.Request) error 24 | // Get returns the value of given key in the session. It returns nil if no such 25 | // key exists. 26 | Get(key interface{}) interface{} 27 | // Set sets the value of given key in the session. 28 | Set(key, val interface{}) 29 | // SetFlash sets the flash to be the given value in the session. 30 | SetFlash(val interface{}) 31 | // Delete deletes a key from the session. 32 | Delete(key interface{}) 33 | // Flush wipes out all existing data in the session. 34 | Flush() 35 | // Encode encodes session data to binary. 36 | Encode() ([]byte, error) 37 | // HasChanged returns whether the session has changed. 38 | HasChanged() bool 39 | } 40 | 41 | // CookieOptions contains options for setting HTTP cookies. 42 | type CookieOptions struct { 43 | // Name is the name of the cookie. Default is "flamego_session". 44 | Name string 45 | // Path is the Path attribute of the cookie. Default is "/". 46 | Path string 47 | // Domain is the Domain attribute of the cookie. Default is not set. 48 | Domain string 49 | // MaxAge is the MaxAge attribute of the cookie. Default is not set. 50 | MaxAge int 51 | // Secure specifies whether to set Secure for the cookie. 52 | Secure bool 53 | // HTTPOnly specifies whether to set HTTPOnly for the cookie. 54 | HTTPOnly bool 55 | // SameSite is the SameSite attribute of the cookie. Default is 56 | // http.SameSiteLaxMode. 57 | SameSite http.SameSite 58 | } 59 | 60 | // Options contains options for the session.Sessioner middleware. 61 | type Options struct { 62 | // Initer is the initialization function of the session store. Default is 63 | // session.MemoryIniter. 64 | Initer Initer 65 | // Config is the configuration object to be passed to the Initer for the session 66 | // store. 67 | Config interface{} 68 | // Cookie is a set of options for setting HTTP cookies. 69 | Cookie CookieOptions 70 | // IDLength specifies the length of session IDs. Default is 16. 71 | IDLength int 72 | // GCInterval is the time interval for GC operations. Default is 5 minutes. 73 | GCInterval time.Duration 74 | // ErrorFunc is the function used to print errors when something went wrong on 75 | // the background. Default is to drop errors silently. 76 | ErrorFunc func(err error) 77 | // ReadIDFunc is the function to read session ID from the request. Default is 78 | // reading from cookie. 79 | ReadIDFunc func(r *http.Request) string 80 | // WriteIDFunc is the function to write session ID to the response. Default is 81 | // writing to cookie. The `created` argument indicates whether a new session was 82 | // created in the session store. 83 | WriteIDFunc func(w http.ResponseWriter, r *http.Request, sid string, created bool) 84 | } 85 | 86 | const minimumSIDLength = 3 87 | 88 | var ErrMinimumSIDLength = errors.Errorf("the SID does not have the minimum required length %d", minimumSIDLength) 89 | 90 | // Sessioner returns a middleware handler that injects session.Session and 91 | // session.Store into the request context, which are used for manipulating 92 | // session data. 93 | func Sessioner(opts ...Options) flamego.Handler { 94 | var opt Options 95 | if len(opts) > 0 { 96 | opt = opts[0] 97 | } 98 | 99 | parseOptions := func(opts Options) Options { 100 | if opts.Initer == nil { 101 | opts.Initer = MemoryIniter() 102 | } 103 | 104 | if reflect.DeepEqual(opts.Cookie, CookieOptions{}) { 105 | opts.Cookie = CookieOptions{ 106 | HTTPOnly: true, 107 | } 108 | } 109 | if opts.Cookie.Name == "" { 110 | opts.Cookie.Name = "flamego_session" 111 | } 112 | if opts.Cookie.SameSite < http.SameSiteDefaultMode || opts.Cookie.SameSite > http.SameSiteNoneMode { 113 | opts.Cookie.SameSite = http.SameSiteLaxMode 114 | } 115 | if opts.Cookie.Path == "" { 116 | opts.Cookie.Path = "/" 117 | } 118 | 119 | // NOTE: The file store requires at least 3 characters for the filename. 120 | if opts.IDLength < minimumSIDLength { 121 | opts.IDLength = 16 122 | } 123 | 124 | if opts.GCInterval.Seconds() < 1 { 125 | opts.GCInterval = 5 * time.Minute 126 | } 127 | 128 | if opts.ErrorFunc == nil { 129 | opts.ErrorFunc = func(error) {} 130 | } 131 | 132 | if opts.ReadIDFunc == nil { 133 | opts.ReadIDFunc = func(r *http.Request) string { 134 | cookie, err := r.Cookie(opts.Cookie.Name) 135 | if err != nil { 136 | return "" 137 | } 138 | return cookie.Value 139 | } 140 | } 141 | if opts.WriteIDFunc == nil { 142 | opts.WriteIDFunc = func(w http.ResponseWriter, r *http.Request, sid string, created bool) { 143 | if !created { 144 | return 145 | } 146 | 147 | cookie := &http.Cookie{ 148 | Name: opts.Cookie.Name, 149 | Value: sid, 150 | Path: opts.Cookie.Path, 151 | Domain: opts.Cookie.Domain, 152 | MaxAge: opts.Cookie.MaxAge, 153 | Secure: opts.Cookie.Secure, 154 | HttpOnly: opts.Cookie.HTTPOnly, 155 | SameSite: opts.Cookie.SameSite, 156 | } 157 | http.SetCookie(w, cookie) 158 | r.AddCookie(cookie) 159 | } 160 | } 161 | return opts 162 | } 163 | 164 | opt = parseOptions(opt) 165 | ctx := context.Background() 166 | 167 | store, err := opt.Initer( 168 | ctx, 169 | opt.Config, 170 | IDWriter(func(w http.ResponseWriter, r *http.Request, sid string) { 171 | opt.WriteIDFunc(w, r, sid, true) 172 | }), 173 | ) 174 | if err != nil { 175 | panic("session: " + err.Error()) 176 | } 177 | 178 | mgr := newManager(store) 179 | mgr.startGC(ctx, opt.GCInterval, opt.ErrorFunc) 180 | 181 | return flamego.ContextInvoker(func(c flamego.Context) { 182 | sid := opt.ReadIDFunc(c.Request().Request) 183 | sess, created, err := mgr.load(c.Request().Request, sid, opt.IDLength) 184 | if err != nil { 185 | if errors.Is(err, context.Canceled) { 186 | c.ResponseWriter().WriteHeader(http.StatusUnprocessableEntity) 187 | return 188 | } 189 | panic("session: load: " + err.Error()) 190 | } 191 | opt.WriteIDFunc(c.ResponseWriter(), c.Request().Request, sess.ID(), created) 192 | 193 | flash := sess.Get(flashKey) 194 | if flash != nil { 195 | sess.Delete(flashKey) 196 | } 197 | 198 | c.Map(store, sess) 199 | c.MapTo(flash, (*Flash)(nil)) 200 | c.Next() 201 | 202 | if sess.HasChanged() { 203 | err = store.Save(c.Request().Context(), sess) 204 | } else { 205 | err = store.Touch(c.Request().Context(), sess.ID()) 206 | } 207 | if err != nil && !errors.Is(err, context.Canceled) { 208 | panic("session: save: " + err.Error()) 209 | } 210 | }) 211 | } 212 | -------------------------------------------------------------------------------- /mysql/mysql.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package mysql 6 | 7 | import ( 8 | "context" 9 | "database/sql" 10 | "fmt" 11 | "time" 12 | 13 | _ "github.com/go-sql-driver/mysql" 14 | "github.com/pkg/errors" 15 | 16 | "github.com/flamego/session" 17 | ) 18 | 19 | var _ session.Store = (*mysqlStore)(nil) 20 | 21 | // mysqlStore is a MySQL implementation of the session store. 22 | type mysqlStore struct { 23 | nowFunc func() time.Time // The function to return the current time 24 | lifetime time.Duration // The duration to have no access to a session before being recycled 25 | db *sql.DB // The database connection 26 | table string // The database table for storing session data 27 | 28 | encoder session.Encoder 29 | decoder session.Decoder 30 | idWriter session.IDWriter 31 | } 32 | 33 | // newMySQLStore returns a new MySQL session store based on given configuration. 34 | func newMySQLStore(cfg Config, idWriter session.IDWriter) *mysqlStore { 35 | return &mysqlStore{ 36 | nowFunc: cfg.nowFunc, 37 | lifetime: cfg.Lifetime, 38 | db: cfg.db, 39 | table: cfg.Table, 40 | encoder: cfg.Encoder, 41 | decoder: cfg.Decoder, 42 | idWriter: idWriter, 43 | } 44 | } 45 | 46 | func quoteWithBackticks(s string) string { 47 | return "`" + s + "`" 48 | } 49 | 50 | func (s *mysqlStore) Exist(ctx context.Context, sid string) bool { 51 | var exists bool 52 | q := fmt.Sprintf( 53 | `SELECT EXISTS (SELECT 1 FROM %s WHERE %s = ?)`, 54 | quoteWithBackticks(s.table), 55 | quoteWithBackticks("key"), 56 | ) 57 | err := s.db.QueryRowContext(ctx, q, sid).Scan(&exists) 58 | return err == nil && exists 59 | } 60 | 61 | func (s *mysqlStore) Read(ctx context.Context, sid string) (session.Session, error) { 62 | var binary []byte 63 | var expiredAt time.Time 64 | q := fmt.Sprintf( 65 | `SELECT data, expired_at FROM %s WHERE %s = ?`, 66 | quoteWithBackticks(s.table), 67 | quoteWithBackticks("key"), 68 | ) 69 | err := s.db.QueryRowContext(ctx, q, sid).Scan(&binary, &expiredAt) 70 | if err == nil { 71 | // Discard existing data if it's expired 72 | if !s.nowFunc().Before(expiredAt.Add(s.lifetime)) { 73 | return session.NewBaseSession(sid, s.encoder, s.idWriter), nil 74 | } 75 | 76 | data, err := s.decoder(binary) 77 | if err != nil { 78 | return nil, errors.Wrap(err, "decode") 79 | } 80 | return session.NewBaseSessionWithData(sid, s.encoder, s.idWriter, data), nil 81 | } else if err != sql.ErrNoRows { 82 | return nil, errors.Wrap(err, "select") 83 | } 84 | 85 | return session.NewBaseSession(sid, s.encoder, s.idWriter), nil 86 | } 87 | 88 | func (s *mysqlStore) Destroy(ctx context.Context, sid string) error { 89 | q := fmt.Sprintf( 90 | `DELETE FROM %s WHERE %s = ?`, 91 | quoteWithBackticks(s.table), 92 | quoteWithBackticks("key"), 93 | ) 94 | _, err := s.db.ExecContext(ctx, q, sid) 95 | return err 96 | } 97 | 98 | func (s *mysqlStore) Touch(ctx context.Context, sid string) error { 99 | q := fmt.Sprintf(`UPDATE %s SET expired_at = ? WHERE %s = ?`, 100 | quoteWithBackticks(s.table), 101 | quoteWithBackticks("key"), 102 | ) 103 | _, err := s.db.ExecContext(ctx, q, s.nowFunc().Add(s.lifetime).UTC(), sid) 104 | if err != nil { 105 | return errors.Wrap(err, "update") 106 | } 107 | return nil 108 | } 109 | 110 | func (s *mysqlStore) Save(ctx context.Context, sess session.Session) error { 111 | binary, err := sess.Encode() 112 | if err != nil { 113 | return errors.Wrap(err, "encode") 114 | } 115 | 116 | q := fmt.Sprintf(` 117 | INSERT INTO %s (%s, data, expired_at) 118 | VALUES (?, ?, ?) 119 | ON DUPLICATE KEY UPDATE 120 | data = VALUES(data), 121 | expired_at = VALUES(expired_at) 122 | `, 123 | quoteWithBackticks(s.table), 124 | quoteWithBackticks("key"), 125 | ) 126 | _, err = s.db.ExecContext(ctx, q, sess.ID(), binary, s.nowFunc().Add(s.lifetime).UTC()) 127 | if err != nil { 128 | return errors.Wrap(err, "upsert") 129 | } 130 | return nil 131 | } 132 | 133 | func (s *mysqlStore) GC(ctx context.Context) error { 134 | q := fmt.Sprintf(`DELETE FROM %s WHERE expired_at <= ?`, quoteWithBackticks(s.table)) 135 | _, err := s.db.ExecContext(ctx, q, s.nowFunc().UTC()) 136 | return err 137 | } 138 | 139 | // Config contains options for the MySQL session store. 140 | type Config struct { 141 | // For tests only 142 | nowFunc func() time.Time 143 | db *sql.DB 144 | 145 | // Lifetime is the duration to have access to a session before being 146 | // recycled. Default is 3600 seconds. 147 | Lifetime time.Duration 148 | // DSN is the database source name to the MySQL. 149 | DSN string 150 | // Table is the table name for storing session data. Default is "sessions". 151 | Table string 152 | // Encoder is the encoder to encode session data. Default is session.GobEncoder. 153 | Encoder session.Encoder 154 | // Decoder is the decoder to decode session data. Default is session.GobDecoder. 155 | Decoder session.Decoder 156 | // InitTable indicates whether to create a default session table when not exists automatically. 157 | InitTable bool 158 | } 159 | 160 | // Initer returns the session.Initer for the MySQL session store. 161 | func Initer() session.Initer { 162 | return func(ctx context.Context, args ...interface{}) (session.Store, error) { 163 | var cfg *Config 164 | var idWriter session.IDWriter 165 | for i := range args { 166 | switch v := args[i].(type) { 167 | case Config: 168 | cfg = &v 169 | case session.IDWriter: 170 | idWriter = v 171 | } 172 | } 173 | if idWriter == nil { 174 | return nil, errors.New("IDWriter not given") 175 | } 176 | 177 | if cfg == nil { 178 | return nil, fmt.Errorf("config object with the type '%T' not found", Config{}) 179 | } else if cfg.DSN == "" && cfg.db == nil { 180 | return nil, errors.New("empty DSN") 181 | } 182 | 183 | if cfg.db == nil { 184 | db, err := sql.Open("mysql", cfg.DSN) 185 | if err != nil { 186 | return nil, errors.Wrap(err, "open database") 187 | } 188 | cfg.db = db 189 | } 190 | 191 | if cfg.InitTable { 192 | q := fmt.Sprintf(` 193 | CREATE TABLE IF NOT EXISTS sessions ( 194 | %[1]s VARCHAR(255) NOT NULL, 195 | data BLOB NOT NULL, 196 | expired_at DATETIME NOT NULL, 197 | PRIMARY KEY (%[1]s) 198 | ) DEFAULT CHARSET=utf8`, 199 | quoteWithBackticks("key"), 200 | ) 201 | 202 | _, err := cfg.db.ExecContext(ctx, q) 203 | if err != nil { 204 | return nil, errors.Wrap(err, "create table") 205 | } 206 | } 207 | 208 | if cfg.nowFunc == nil { 209 | cfg.nowFunc = time.Now 210 | } 211 | if cfg.Lifetime.Seconds() < 1 { 212 | cfg.Lifetime = 3600 * time.Second 213 | } 214 | if cfg.Table == "" { 215 | cfg.Table = "sessions" 216 | } 217 | if cfg.Encoder == nil { 218 | cfg.Encoder = session.GobEncoder 219 | } 220 | if cfg.Decoder == nil { 221 | cfg.Decoder = session.GobDecoder 222 | } 223 | 224 | return newMySQLStore(*cfg, idWriter), nil 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /postgres/postgres_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package postgres 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "database/sql" 11 | "flag" 12 | "fmt" 13 | "net/http" 14 | "net/http/httptest" 15 | "net/url" 16 | "os" 17 | "sync" 18 | "testing" 19 | "time" 20 | 21 | "github.com/flamego/flamego" 22 | "github.com/jackc/pgx/v5" 23 | "github.com/jackc/pgx/v5/log/testingadapter" 24 | "github.com/jackc/pgx/v5/stdlib" 25 | "github.com/jackc/pgx/v5/tracelog" 26 | "github.com/stretchr/testify/assert" 27 | "github.com/stretchr/testify/require" 28 | 29 | "github.com/flamego/session" 30 | ) 31 | 32 | var flagParseOnce sync.Once 33 | 34 | func newTestDB(t *testing.T, ctx context.Context) (testDB *sql.DB, cleanup func() error) { 35 | dsn := os.ExpandEnv("postgres://$PGUSER:$PGPASSWORD@$PGHOST:$PGPORT/?sslmode=$PGSSLMODE") 36 | db, err := openDB(dsn) 37 | if err != nil { 38 | t.Fatalf("Failed to open database: %v", err) 39 | } 40 | 41 | dbname := "flamego-test-sessions" 42 | _, err = db.ExecContext(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbname)) 43 | if err != nil { 44 | t.Fatalf("Failed to drop test database: %v", err) 45 | } 46 | 47 | _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbname)) 48 | if err != nil { 49 | t.Fatalf("Failed to create test database: %v", err) 50 | } 51 | 52 | cfg, err := url.Parse(dsn) 53 | if err != nil { 54 | t.Fatalf("Failed to parse DSN: %v", err) 55 | } 56 | cfg.Path = "/" + dbname 57 | 58 | flagParseOnce.Do(flag.Parse) 59 | 60 | connConfig, err := pgx.ParseConfig(cfg.String()) 61 | if err != nil { 62 | t.Fatalf("Failed to parse test database config: %v", err) 63 | } 64 | if testing.Verbose() { 65 | connConfig.Tracer = &tracelog.TraceLog{ 66 | Logger: testingadapter.NewLogger(t), 67 | LogLevel: tracelog.LogLevelTrace, 68 | } 69 | } 70 | 71 | testDB = stdlib.OpenDB(*connConfig) 72 | 73 | t.Cleanup(func() { 74 | defer func() { _ = db.Close() }() 75 | 76 | if t.Failed() { 77 | t.Logf("DATABASE %s left intact for inspection", dbname) 78 | return 79 | } 80 | 81 | err := testDB.Close() 82 | if err != nil { 83 | t.Fatalf("Failed to close test connection: %v", err) 84 | } 85 | 86 | _, err = db.ExecContext(ctx, fmt.Sprintf(`DROP DATABASE %q`, dbname)) 87 | if err != nil { 88 | t.Fatalf("Failed to drop test database: %v", err) 89 | } 90 | }) 91 | return testDB, func() error { 92 | if t.Failed() { 93 | return nil 94 | } 95 | 96 | _, err = testDB.ExecContext(ctx, `TRUNCATE sessions RESTART IDENTITY CASCADE`) 97 | if err != nil { 98 | return err 99 | } 100 | return nil 101 | } 102 | } 103 | 104 | func TestPostgresStore(t *testing.T) { 105 | ctx := context.Background() 106 | db, cleanup := newTestDB(t, ctx) 107 | t.Cleanup(func() { 108 | assert.Nil(t, cleanup()) 109 | }) 110 | 111 | f := flamego.NewWithLogger(&bytes.Buffer{}) 112 | f.Use(session.Sessioner( 113 | session.Options{ 114 | Initer: Initer(), 115 | Config: Config{ 116 | nowFunc: time.Now, 117 | db: db, 118 | InitTable: true, 119 | }, 120 | }, 121 | )) 122 | 123 | f.Get("/set", func(s session.Session) { 124 | s.Set("username", "flamego") 125 | }) 126 | f.Get("/get", func(s session.Session) { 127 | sid := s.ID() 128 | assert.Len(t, sid, 16) 129 | 130 | username, ok := s.Get("username").(string) 131 | assert.True(t, ok) 132 | assert.Equal(t, "flamego", username) 133 | 134 | s.Delete("username") 135 | _, ok = s.Get("username").(string) 136 | assert.False(t, ok) 137 | 138 | s.Set("random", "value") 139 | s.Flush() 140 | _, ok = s.Get("random").(string) 141 | assert.False(t, ok) 142 | }) 143 | f.Get("/destroy", func(c flamego.Context, session session.Session, store session.Store) error { 144 | return store.Destroy(c.Request().Context(), session.ID()) 145 | }) 146 | 147 | resp := httptest.NewRecorder() 148 | req, err := http.NewRequest("GET", "/set", nil) 149 | require.Nil(t, err) 150 | 151 | f.ServeHTTP(resp, req) 152 | assert.Equal(t, http.StatusOK, resp.Code) 153 | 154 | cookie := resp.Header().Get("Set-Cookie") 155 | 156 | resp = httptest.NewRecorder() 157 | req, err = http.NewRequest("GET", "/get", nil) 158 | require.Nil(t, err) 159 | 160 | req.Header.Set("Cookie", cookie) 161 | f.ServeHTTP(resp, req) 162 | assert.Equal(t, http.StatusOK, resp.Code) 163 | 164 | resp = httptest.NewRecorder() 165 | req, err = http.NewRequest("GET", "/destroy", nil) 166 | require.Nil(t, err) 167 | 168 | req.Header.Set("Cookie", cookie) 169 | f.ServeHTTP(resp, req) 170 | assert.Equal(t, http.StatusOK, resp.Code) 171 | } 172 | 173 | func TestPostgresStore_GC(t *testing.T) { 174 | ctx := context.Background() 175 | db, cleanup := newTestDB(t, ctx) 176 | t.Cleanup(func() { 177 | assert.Nil(t, cleanup()) 178 | }) 179 | 180 | now := time.Now() 181 | store, err := Initer()(ctx, 182 | Config{ 183 | nowFunc: func() time.Time { return now }, 184 | db: db, 185 | Lifetime: time.Second, 186 | InitTable: true, 187 | }, 188 | session.IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 189 | ) 190 | require.Nil(t, err) 191 | 192 | sess1, err := store.Read(ctx, "1") 193 | require.Nil(t, err) 194 | err = store.Save(ctx, sess1) 195 | require.Nil(t, err) 196 | 197 | now = now.Add(-2 * time.Second) 198 | sess2, err := store.Read(ctx, "2") 199 | require.Nil(t, err) 200 | 201 | sess2.Set("name", "flamego") 202 | err = store.Save(ctx, sess2) 203 | require.Nil(t, err) 204 | 205 | // Read on an expired session should wipe data but preserve the record 206 | now = now.Add(2 * time.Second) 207 | tmp, err := store.Read(ctx, "2") 208 | require.Nil(t, err) 209 | assert.Nil(t, tmp.Get("name")) 210 | 211 | now = now.Add(-2 * time.Second) 212 | sess3, err := store.Read(ctx, "3") 213 | require.Nil(t, err) 214 | err = store.Save(ctx, sess3) 215 | require.Nil(t, err) 216 | 217 | now = now.Add(2 * time.Second) 218 | err = store.GC(ctx) // sess3 should be recycled 219 | require.Nil(t, err) 220 | 221 | assert.True(t, store.Exist(ctx, "1")) 222 | assert.False(t, store.Exist(ctx, "2")) 223 | assert.False(t, store.Exist(ctx, "3")) 224 | } 225 | 226 | func TestPostgresStore_Touch(t *testing.T) { 227 | ctx := context.Background() 228 | db, cleanup := newTestDB(t, ctx) 229 | t.Cleanup(func() { 230 | assert.Nil(t, cleanup()) 231 | }) 232 | 233 | now := time.Now() 234 | store, err := Initer()(ctx, 235 | Config{ 236 | nowFunc: func() time.Time { return now }, 237 | db: db, 238 | Lifetime: time.Second, 239 | InitTable: true, 240 | }, 241 | session.IDWriter(func(http.ResponseWriter, *http.Request, string) {}), 242 | ) 243 | require.Nil(t, err) 244 | 245 | sess, err := store.Read(ctx, "1") 246 | require.Nil(t, err) 247 | err = store.Save(ctx, sess) 248 | require.Nil(t, err) 249 | 250 | now = now.Add(2 * time.Second) 251 | // Touch should keep the session alive 252 | err = store.Touch(ctx, sess.ID()) 253 | require.Nil(t, err) 254 | 255 | err = store.GC(ctx) 256 | require.Nil(t, err) 257 | assert.True(t, store.Exist(ctx, sess.ID())) 258 | } 259 | -------------------------------------------------------------------------------- /memory.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Flamego. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package session 6 | 7 | import ( 8 | "container/heap" 9 | "context" 10 | "sync" 11 | "time" 12 | 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | var _ Session = (*memorySession)(nil) 17 | 18 | // memorySession is an in-memory session. 19 | type memorySession struct { 20 | *BaseSession 21 | 22 | lock sync.RWMutex // The mutex to guard accesses to the lastAccessedAt 23 | lastAccessedAt time.Time // The last time of the session being accessed 24 | 25 | index int // The index in the heap 26 | } 27 | 28 | // newMemorySession returns a new memory session with given session ID. 29 | func newMemorySession(sid string, idWriter IDWriter) *memorySession { 30 | return &memorySession{ 31 | BaseSession: NewBaseSession(sid, nil, idWriter), 32 | } 33 | } 34 | 35 | func (s *memorySession) LastAccessedAt() time.Time { 36 | s.lock.RLock() 37 | defer s.lock.RUnlock() 38 | return s.lastAccessedAt 39 | } 40 | 41 | func (s *memorySession) SetLastAccessedAt(t time.Time) { 42 | s.lock.Lock() 43 | defer s.lock.Unlock() 44 | s.lastAccessedAt = t 45 | } 46 | 47 | var _ Store = (*memoryStore)(nil) 48 | 49 | // memoryStore is an in-memory implementation of the session store. 50 | type memoryStore struct { 51 | nowFunc func() time.Time // The function to return the current time 52 | lifetime time.Duration // The duration to have no access to a session before being recycled 53 | 54 | lock sync.RWMutex // The mutex to guard accesses to the heap and index 55 | heap []*memorySession // The heap to be managed by operations of heap.Interface 56 | index map[string]*memorySession // The index to be managed by operations of heap.Interface 57 | 58 | idWriter IDWriter 59 | } 60 | 61 | // newMemoryStore returns a new memory session store based on given 62 | // configuration. 63 | func newMemoryStore(cfg MemoryConfig, idWriter IDWriter) *memoryStore { 64 | return &memoryStore{ 65 | nowFunc: cfg.nowFunc, 66 | lifetime: cfg.Lifetime, 67 | index: make(map[string]*memorySession), 68 | idWriter: idWriter, 69 | } 70 | } 71 | 72 | // Len implements `heap.Interface.Len`. It is not concurrent-safe and is the 73 | // caller's responsibility to ensure they're being guarded by a mutex during any 74 | // heap operation, i.e. heap.Fix, heap.Remove, heap.Push, heap.Pop. 75 | func (s *memoryStore) Len() int { 76 | return len(s.heap) 77 | } 78 | 79 | // Less implements `heap.Interface.Less`. It is not concurrent-safe and is the 80 | // caller's responsibility to ensure they're being guarded by a mutex during any 81 | // heap operation, i.e. heap.Fix, heap.Remove, heap.Push, heap.Pop. 82 | func (s *memoryStore) Less(i, j int) bool { 83 | return s.heap[i].LastAccessedAt().Before(s.heap[j].LastAccessedAt()) 84 | } 85 | 86 | // Swap implements `heap.Interface.Swap`. It is not concurrent-safe and is the 87 | // caller's responsibility to ensure they're being guarded by a mutex during any 88 | // heap operation, i.e. heap.Fix, heap.Remove, heap.Push, heap.Pop. 89 | func (s *memoryStore) Swap(i, j int) { 90 | s.heap[i], s.heap[j] = s.heap[j], s.heap[i] 91 | s.heap[i].index = i 92 | s.heap[j].index = j 93 | } 94 | 95 | // Push implements `heap.Interface.Push`. It is not concurrent-safe and is the 96 | // caller's responsibility to ensure they're being guarded by a mutex during any 97 | // heap operation, i.e. heap.Fix, heap.Remove, heap.Push, heap.Pop. 98 | func (s *memoryStore) Push(x interface{}) { 99 | n := s.Len() 100 | sess := x.(*memorySession) 101 | sess.index = n 102 | s.heap = append(s.heap, sess) 103 | s.index[sess.sid] = sess 104 | } 105 | 106 | // Pop implements `heap.Interface.Pop`. It is not concurrent-safe and is the 107 | // caller's responsibility to ensure they're being guarded by a mutex during any 108 | // heap operation, i.e. heap.Fix, heap.Remove, heap.Push, heap.Pop. 109 | func (s *memoryStore) Pop() interface{} { 110 | n := s.Len() 111 | sess := s.heap[n-1] 112 | 113 | s.heap[n-1] = nil // Avoid memory leak 114 | sess.index = -1 // For safety 115 | 116 | s.heap = s.heap[:n-1] 117 | delete(s.index, sess.sid) 118 | return sess 119 | } 120 | 121 | func (s *memoryStore) Exist(_ context.Context, sid string) bool { 122 | s.lock.RLock() 123 | defer s.lock.RUnlock() 124 | 125 | _, ok := s.index[sid] 126 | return ok 127 | } 128 | 129 | func (s *memoryStore) Read(_ context.Context, sid string) (Session, error) { 130 | s.lock.Lock() 131 | defer s.lock.Unlock() 132 | 133 | sess, ok := s.index[sid] 134 | if ok { 135 | // Discard existing data if it's expired 136 | if !s.nowFunc().Before(sess.LastAccessedAt().Add(s.lifetime)) { 137 | sess.data = make(Data) 138 | } 139 | sess.SetLastAccessedAt(s.nowFunc()) 140 | heap.Fix(s, sess.index) 141 | return sess, nil 142 | } 143 | 144 | sess = newMemorySession(sid, s.idWriter) 145 | sess.SetLastAccessedAt(s.nowFunc()) 146 | heap.Push(s, sess) 147 | return sess, nil 148 | } 149 | 150 | func (s *memoryStore) Destroy(_ context.Context, sid string) error { 151 | s.lock.Lock() 152 | defer s.lock.Unlock() 153 | 154 | sess, ok := s.index[sid] 155 | if !ok { 156 | return nil 157 | } 158 | 159 | heap.Remove(s, sess.index) 160 | return nil 161 | } 162 | 163 | func (s *memoryStore) Touch(_ context.Context, sid string) error { 164 | s.lock.Lock() 165 | defer s.lock.Unlock() 166 | 167 | sess, ok := s.index[sid] 168 | if !ok { 169 | return nil 170 | } 171 | 172 | sess.SetLastAccessedAt(s.nowFunc()) 173 | heap.Fix(s, sess.index) 174 | return nil 175 | } 176 | 177 | func (s *memoryStore) Save(context.Context, Session) error { return nil } 178 | 179 | func (s *memoryStore) GC(ctx context.Context) error { 180 | // Removing expired sessions from top of the heap until there is no more expired 181 | // sessions found. 182 | for { 183 | select { 184 | case <-ctx.Done(): 185 | return nil 186 | default: 187 | } 188 | 189 | done := func() bool { 190 | s.lock.Lock() 191 | defer s.lock.Unlock() 192 | 193 | if s.Len() == 0 { 194 | return true 195 | } 196 | 197 | sess := s.heap[0] 198 | 199 | // If the least accessed session is not expired, there is no need to continue 200 | if s.nowFunc().Before(sess.LastAccessedAt().Add(s.lifetime)) { 201 | return true 202 | } 203 | 204 | heap.Remove(s, sess.index) 205 | return false 206 | }() 207 | if done { 208 | break 209 | } 210 | } 211 | return nil 212 | } 213 | 214 | // MemoryConfig contains options for the memory session store. 215 | type MemoryConfig struct { 216 | nowFunc func() time.Time // For tests only 217 | 218 | // Lifetime is the duration to have no access to a session before being 219 | // recycled. Default is 3600 seconds. 220 | Lifetime time.Duration 221 | } 222 | 223 | // MemoryIniter returns the Initer for the memory session store. 224 | func MemoryIniter() Initer { 225 | return func(_ context.Context, args ...interface{}) (Store, error) { 226 | var cfg *MemoryConfig 227 | var idWriter IDWriter 228 | for i := range args { 229 | switch v := args[i].(type) { 230 | case MemoryConfig: 231 | cfg = &v 232 | case IDWriter: 233 | idWriter = v 234 | } 235 | } 236 | if idWriter == nil { 237 | return nil, errors.New("IDWriter not given") 238 | } 239 | 240 | if cfg == nil { 241 | cfg = &MemoryConfig{} 242 | } 243 | 244 | if cfg.nowFunc == nil { 245 | cfg.nowFunc = time.Now 246 | } 247 | if cfg.Lifetime.Seconds() < 1 { 248 | cfg.Lifetime = 3600 * time.Second 249 | } 250 | 251 | return newMemoryStore(*cfg, idWriter), nil 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 4 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 5 | github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U= 6 | github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI= 7 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 8 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 9 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 10 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 11 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 12 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 13 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 14 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 15 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 16 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 17 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 18 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 19 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 20 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 21 | github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 22 | github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 23 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 24 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 25 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 26 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 27 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 28 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 29 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 30 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 32 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 33 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 34 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 35 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 36 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 37 | github.com/flamego/flamego v1.9.7 h1:x3gkGOALg+HkpqFngkxQ3ZMC2vIa3Kze/WIpYTU2L0k= 38 | github.com/flamego/flamego v1.9.7/go.mod h1:m9Uc8FaCRVTpK/HuoK3quBhlHX0cE/DNY5LPXkRok9s= 39 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 40 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 41 | github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= 42 | github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 43 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 44 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 45 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 46 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 47 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 48 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 49 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 50 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 51 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 52 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 53 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 54 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 55 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 56 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 57 | github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= 58 | github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= 59 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 60 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 61 | github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= 62 | github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 63 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 64 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 65 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 66 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 67 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 68 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 69 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 70 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 71 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 72 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 73 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 74 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 75 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 76 | github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= 77 | github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= 78 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 79 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 80 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 81 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 82 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 83 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 84 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 85 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 86 | github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= 87 | github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= 88 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 89 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 90 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 91 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 92 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 93 | github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= 94 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 95 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 96 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 97 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 98 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 99 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 100 | github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= 101 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 102 | github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= 103 | github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= 104 | github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= 105 | github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= 106 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 107 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 108 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= 109 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= 110 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 111 | go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= 112 | go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 113 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 114 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 115 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 116 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 117 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 118 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 119 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 120 | golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= 121 | golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 122 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 123 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 124 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 125 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 126 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 127 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 128 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 129 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 130 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 131 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 132 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 133 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 134 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 135 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 136 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 137 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 138 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 139 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 140 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 141 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 142 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 143 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 144 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 145 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 146 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 147 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 148 | golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= 149 | golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 150 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 151 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 152 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 153 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 154 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 155 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 156 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 157 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 158 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 159 | modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= 160 | modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 161 | modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= 162 | modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= 163 | modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= 164 | modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 165 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 166 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 167 | modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= 168 | modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 169 | modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= 170 | modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= 171 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 172 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 173 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 174 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 175 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 176 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 177 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 178 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 179 | modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= 180 | modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= 181 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 182 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 183 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 184 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 185 | --------------------------------------------------------------------------------