diff --git a/.drone.yml b/.drone.yml index b6e31fe..2da88f6 100644 --- a/.drone.yml +++ b/.drone.yml @@ -3,4 +3,5 @@ pipeline: image: golang commands: - go get github.com/oxtoacart/bpool + - go get github.com/google/uuid - go build \ No newline at end of file diff --git a/README.md b/README.md index 0535127..62236af 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,17 @@ Le fichier de configuration est passé dans `Configure()` et doit être au forma "ServerPort": ":8000", "Domain": "http://git.an", "MainTemplate": "{{define \"main\" }} {{ template \"base\" . }} {{ end }}", - "Debug": false + "Debug": false, + "SessionProvider": "FileProvider" } ``` +Des paramètres par défaut sont utilisé dans tous les cas. + +Le système de session utilise un dossier `.session` généré automatiquement, attention aux permissions ! + +Il est possible de rajouter ses propres backend pour le système de session en les enregistrant grâce à la fonction `SessionProviderRegister`. Les backends doivent respecter l'interface `SessionProvider`. + Le MainTemplate est utilisé pour charger tous les templates, il peut être utilisé tel quel ou modifié si vous savez ce que fous faites. Dans le doute, laissez celui par défaut. On lance enfin le serveur en utilisant `Start()` diff --git a/cookie.go b/cookie.go new file mode 100644 index 0000000..5f0e3be --- /dev/null +++ b/cookie.go @@ -0,0 +1,22 @@ +/* +* @Author: Bartuccio Antoine +* @Date: 2018-07-16 12:22:24 +* @Last Modified by: klmp200 +* @Last Modified time: 2018-07-17 23:27:27 + */ + +package gowebframework + +import ( + "net/http" + "time" +) + +func AddCookie(w http.ResponseWriter, name string, data string, lifetime time.Duration) { + cookie := http.Cookie{ + Name: name, + Value: data, + Expires: time.Now().Add(lifetime), + } + http.SetCookie(w, &cookie) +} diff --git a/file-session-provider.go b/file-session-provider.go new file mode 100644 index 0000000..ac06271 --- /dev/null +++ b/file-session-provider.go @@ -0,0 +1,157 @@ +/* +* @Author: Bartuccio Antoine +* @Date: 2018-07-16 15:37:29 +* @Last Modified by: klmp200 +* @Last Modified time: 2018-07-17 23:47:56 + */ + +package gowebframework + +import ( + "encoding/json" + "io/ioutil" + "log" + "os" + "path/filepath" + "time" +) + +var session_folder_path string = ".session" +var folder_perm os.FileMode = 0770 + +type FileSessionProvider struct{} + +// Used to hide FileSession variables and still be able to write inside json with json.Marshal +type unexportedFileSession struct { + UUID string + LastUse time.Time + Data map[string]interface{} +} +type FileSession struct { + uuid string + lastUse time.Time + data map[string]interface{} +} + +// Convert a unexportedFileSession to a FileSession +func (session unexportedFileSession) fileSession() FileSession { + return FileSession{ + uuid: session.UUID, + lastUse: session.LastUse, + data: session.Data, + } +} + +func (provider FileSessionProvider) checkOrCreateFolder() { + folder, err := os.Open(session_folder_path) + if err != nil { + // Auto create folder if doesn't exist + log.Println("No session folder found, creating " + session_folder_path) + err = os.Mkdir(session_folder_path, folder_perm) + if err != nil { + log.Fatal("Could not create session directory, exiting") + } + return + } + folder_infos, err := folder.Stat() + if err != nil { + log.Fatal(err) + } + if !folder_infos.IsDir() { + log.Fatal(session_folder_path + " is not a directory, exiting") + } + if !isWritable(session_folder_path) { + log.Fatal(session_folder_path + " directory is not writable, exiting") + } +} + +func (provider FileSessionProvider) SessionInit(uuid string) (Session, error) { + provider.checkOrCreateFolder() + provider.SessionDestroy(uuid) + session := FileSession{} + session.uuid = uuid + session.data = make(map[string]interface{}) + session.lastUse = time.Now() + return session, session.writeSession() +} + +func (provider FileSessionProvider) SessionRead(uuid string) (Session, error) { + provider.checkOrCreateFolder() + data, err := ioutil.ReadFile(filepath.Join(session_folder_path, uuid+".json")) + if err != nil { + return provider.SessionInit(uuid) + } + unexported_session := unexportedFileSession{} + err = json.Unmarshal(data, &unexported_session) + if err != nil { + return nil, err + } + session := unexported_session.fileSession() + return session, session.writeSession() +} + +func (provider FileSessionProvider) SessionDestroy(uuid string) error { + provider.checkOrCreateFolder() + return os.Remove(filepath.Join(session_folder_path, uuid+".json")) +} + +func (provider FileSessionProvider) SessionClearExpired(maxLifetime time.Duration) { + provider.checkOrCreateFolder() + directory, _ := ioutil.ReadDir(session_folder_path) + for _, file := range directory { + session := unexportedFileSession{} + data, err := ioutil.ReadFile(filepath.Join(session_folder_path, file.Name())) + if err != nil { + log.Println(err) + } else { + err = json.Unmarshal(data, &session) + if err != nil { + log.Println(err) + } else { + if session.LastUse.Add(maxLifetime).Sub(time.Now()) <= 0 { + // Session expired + provider.SessionDestroy(session.UUID) + } + } + } + } + +} + +func (session FileSession) unexportedFileSession() unexportedFileSession { + return unexportedFileSession{ + UUID: session.uuid, + LastUse: session.lastUse, + Data: session.data, + } +} + +// Write a session on the disk +func (session FileSession) writeSession() error { + session.lastUse = time.Now() + data, _ := json.Marshal(session.unexportedFileSession()) + err := ioutil.WriteFile(filepath.Join(session_folder_path, session.uuid+".json"), data, folder_perm) + return err +} + +func (session FileSession) Set(key string, value interface{}) error { + session.data[key] = value + return session.writeSession() +} + +func (session FileSession) Get(key string) interface{} { + value, ok := session.data[key] + if !ok { + return nil + } + return value +} + +func (session FileSession) Delete(key string) error { + delete(session.data, key) + return session.writeSession() +} + +func (session FileSession) SessionUUID() string { + return session.uuid +} diff --git a/gowebframework.go b/gowebframework.go index eb9143f..680c285 100644 --- a/gowebframework.go +++ b/gowebframework.go @@ -2,7 +2,7 @@ * @Author: Bartuccio Antoine * @Date: 2018-07-14 11:32:11 * @Last Modified by: klmp200 -* @Last Modified time: 2018-07-14 13:18:53 +* @Last Modified time: 2018-07-17 23:55:22 */ package gowebframework @@ -16,6 +16,7 @@ import ( "log" "net/http" "path/filepath" + "time" ) var templates map[string]*template.Template @@ -28,6 +29,7 @@ type Config struct { StaticFolderPath string TemplateExtensionPattern string ServerPort string + SessionProvider string Domain string Debug bool } @@ -38,6 +40,15 @@ var ServerConfig Config func Configure(config_file_name string, custom_config_file_name string) { loadConfiguration(config_file_name, custom_config_file_name) loadTemplates() + // initialise sessions + SessionProviderRegister(FileSessionProvider{}, "FileProvider") + // Put a session of 1 day duration + ses, err := newSessionManager(ServerConfig.SessionProvider, "GOSESSID", time.Hour*24) + if err != nil { + log.Fatal(err) + } + SESSION = ses + go clearExpiredSessionsCron() } // Starts server @@ -47,15 +58,29 @@ func Start() { log.Fatal(http.ListenAndServe(ServerConfig.ServerPort, nil)) } +func loadDefaultConfiguration() { + ServerConfig.StaticFolderPath = "statics/" + ServerConfig.TemplateIncludePath = "templates/" + ServerConfig.TemplateLayoutPath = "templates/layouts/" + ServerConfig.TemplateExtensionPattern = "*.gohtml" + ServerConfig.ServerPort = ":8000" + ServerConfig.Domain = "localhost" + ServerConfig.MainTemplate = `{{define "main" }} {{ template "base" . }} {{ end }}` + ServerConfig.Debug = false + ServerConfig.SessionProvider = "FileProvider" +} + func loadConfiguration(config_file_name string, custom_config_file_name string) { + loadDefaultConfiguration() default_settings, err := ioutil.ReadFile(config_file_name) if err != nil { - log.Fatal("No " + config_file_name + " found, exiting") - } - log.Println("Importing settings") - err = json.Unmarshal(default_settings, &ServerConfig) - if err != nil { - log.Fatal("Malformed " + config_file_name) + log.Println("No " + config_file_name + " found, using default conf instead") + } else { + log.Println("Importing settings") + err = json.Unmarshal(default_settings, &ServerConfig) + if err != nil { + log.Fatal("Malformed " + config_file_name) + } } custom_settings, err := ioutil.ReadFile(custom_config_file_name) diff --git a/is-writable.go b/is-writable.go new file mode 100644 index 0000000..3cb3a92 --- /dev/null +++ b/is-writable.go @@ -0,0 +1,46 @@ +/* +* @Author: Bartuccio Antoine +* @Date: 2018-07-17 18:37:38 +* @Last Modified by: klmp200 +* @Last Modified time: 2018-07-17 19:35:18 + */ +// +build !windows + +package gowebframework + +import ( + "log" + "os" + "syscall" +) + +// Check if a file or directory is writable +func isWritable(path string) bool { + os_stats, err := os.Stat(path) + if err != nil { + // Error reading file or folder + log.Println(err) + return false + } + if os_stats.Mode().Perm()&(1<<(uint(7))) == 0 { + // Check if user bit is enabled + log.Println("User bit on " + path + " is not enabled") + return false + } + + var syscall_stats syscall.Stat_t + err = syscall.Stat(path, &syscall_stats) + if err != nil { + // Error getting stats + log.Println(err) + return false + } + + if uint32(os.Getuid()) != syscall_stats.Uid { + // User doesn't have permission to write + log.Println("No permission to write on " + path) + return false + } + + return true +} diff --git a/is-writable_windows.go b/is-writable_windows.go new file mode 100644 index 0000000..5133dfa --- /dev/null +++ b/is-writable_windows.go @@ -0,0 +1,30 @@ +/* +* @Author: Bartuccio Antoine +* @Date: 2018-07-17 18:51:12 +* @Last Modified by: klmp200 +* @Last Modified time: 2018-07-17 18:54:20 + */ + +// WARNING : Not tested ! + +package gowebframework + +import ( + "os" +) + +func isWritable(path string) bool { + stats, err := os.Stat(path) + if err != nil { + // Folder or file doesn't exist + return false + } + + if stats.Mode().Perm()&(1<<(uint(7))) == 0 { + // Check if the user bit is enabled in file permission + // It's the only thing we can do on windows + return false + } + + return true +} diff --git a/session.go b/session.go new file mode 100644 index 0000000..6051206 --- /dev/null +++ b/session.go @@ -0,0 +1,105 @@ +/* +* @Author: Bartuccio Antoine +* @Date: 2018-07-16 14:07:07 +* @Last Modified by: klmp200 +* @Last Modified time: 2018-07-17 23:57:10 + */ + +package gowebframework + +import ( + "fmt" + "github.com/google/uuid" + "log" + "net/http" + "net/url" + "sync" + "time" +) + +var SESSION *SessionManager +var providers = make(map[string]Provider) + +type SessionManager struct { + cookieName string // Name of the cookie on the client side + lock sync.Mutex + provider Provider + maxLifetime time.Duration +} + +type Session interface { + Set(key string, value interface{}) error // set a session value + Get(key string) interface{} // get session value + Delete(key string) error // delete session value + SessionUUID() string // get the current session UUID +} + +type Provider interface { + SessionInit(uuid string) (Session, error) + SessionRead(uuid string) (Session, error) + SessionDestroy(uuid string) error + SessionClearExpired(maxLifetime time.Duration) +} + +func (manager *SessionManager) generateUUID() string { + return uuid.New().String() +} + +// Launch a Clear Expired Sessions task at fixed rate +func clearExpiredSessionsCron() { + SESSION.lock.Lock() + lifetime := SESSION.maxLifetime + SESSION.lock.Unlock() + for { + log.Println("pute") + SESSION.lock.Lock() + SESSION.provider.SessionClearExpired(lifetime) + SESSION.lock.Unlock() + time.Sleep(lifetime) + } +} + +func SessionProviderRegister(provider Provider, providerName string) error { + if provider == nil { + return fmt.Errorf("No provider found for provider name %s", providerName) + } + if _, exists := providers[providerName]; exists { + return fmt.Errorf("Provider with name %s already exists", providerName) + } + providers[providerName] = provider + return nil +} + +func (manager *SessionManager) GetSession(w http.ResponseWriter, r *http.Request) Session { + var session Session + manager.lock.Lock() + defer manager.lock.Unlock() + cookie, err := r.Cookie(manager.cookieName) + if err != nil || cookie.Value == "" { + // Create a new session + session_id := manager.generateUUID() + s, err := manager.provider.SessionInit(session_id) + if err != nil { + log.Fatal(err) + } + session = s + AddCookie(w, manager.cookieName, url.QueryEscape(session_id), manager.maxLifetime) + } else { + // Get the session + session_id, _ := url.QueryUnescape(cookie.Value) + s, err := manager.provider.SessionRead(session_id) + if err != nil { + log.Fatal(err) + } + session = s + } + return session +} + +func newSessionManager(providerName string, cookieName string, maxLifetime time.Duration) (*SessionManager, error) { + provider, ok := providers[providerName] + if !ok { + return nil, fmt.Errorf("Session error: unknown provider %s", providerName) + } + return &SessionManager{provider: provider, cookieName: cookieName, maxLifetime: maxLifetime}, nil +}