From 6576f45eac5a0acbec7e5fda9de72603b3e34006 Mon Sep 17 00:00:00 2001 From: rmanach Date: Thu, 2 Jan 2025 18:59:37 +0100 Subject: [PATCH] init repo --- .env.example | 7 + .gitignore | 3 + Makefile | 12 + go.mod | 11 + go.sum | 15 ++ handlers/login/handler.go | 184 +++++++++++++++ handlers/login/templates/form.html.tpl | 66 ++++++ handlers/login/templates/success.html.tpl | 13 ++ handlers/login/templates/templates.go | 42 ++++ handlers/upload/handler.go | 258 ++++++++++++++++++++++ handlers/upload/templates/form.html.tpl | 106 +++++++++ handlers/upload/templates/templates.go | 46 ++++ main.go | 53 +++++ server/server.go | 90 ++++++++ services/sessions.go | 162 ++++++++++++++ 15 files changed, 1068 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers/login/handler.go create mode 100644 handlers/login/templates/form.html.tpl create mode 100644 handlers/login/templates/success.html.tpl create mode 100644 handlers/login/templates/templates.go create mode 100644 handlers/upload/handler.go create mode 100644 handlers/upload/templates/form.html.tpl create mode 100644 handlers/upload/templates/templates.go create mode 100644 main.go create mode 100644 server/server.go create mode 100644 services/sessions.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3c1bb3b --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +API_ADMIN_USERNAME= +API_ADMIN_PASSWORD= + +API_SESSION_EXPIRATION_DURATION= # in seconds + +API_PORT= +API_SECURE= # default to "false" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd159b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +builds + +.env \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0f018a9 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +test: + go test ./... -race + +build: lint + mkdir -p builds + go build -o builds/librapi main.go + +lint: + golangci-lint run --fix + +run: lint + go run main.go \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..59147c3 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module librapi + +go 1.22.4 + +require github.com/rs/zerolog v1.33.0 + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + golang.org/x/sys v0.12.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..98afda4 --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/handlers/login/handler.go b/handlers/login/handler.go new file mode 100644 index 0000000..aaeb074 --- /dev/null +++ b/handlers/login/handler.go @@ -0,0 +1,184 @@ +package login + +import ( + "bytes" + "errors" + "fmt" + "librapi/handlers/login/templates" + "librapi/services" + "net/http" + "sync" + + "github.com/rs/zerolog/log" +) + +var ( + adminUsername = sync.OnceValue[string](func() string { + return "test" + }) + + adminPassword = sync.OnceValue[string](func() string { + return "test" + }) +) + +var ( + ErrInvalidUsername = errors.New("username must not be empty") + ErrInvalidPassword = errors.New("password must not be empty") + ErrInvalidCredentials = errors.New("bad credentials") +) + +type LoginField struct { + Name string + Value string + Err string +} + +type LoginForm struct { + Username LoginField + Password LoginField + Error error + Method string +} + +func NewLoginForm() LoginForm { + return LoginForm{ + Username: LoginField{ + Name: "username", + }, + Password: LoginField{ + Name: "password", + }, + Method: http.MethodPost, + } +} + +func (lf *LoginForm) HasErrors() bool { + return lf.Username.Err != "" || lf.Password.Err != "" +} + +func (lf *LoginForm) IsSuccess() bool { + return lf.Method == http.MethodPost && lf.Error != nil && !lf.HasErrors() +} + +func (lf *LoginForm) ValidCredentials() bool { + return lf.Username.Value == adminUsername() && lf.Password.Value == adminPassword() +} + +func Handler(s *services.SessionStore) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getLogin(w, r, s) + case http.MethodPost: + postLogin(w, r, s) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + } +} + +func extractLoginForm(r *http.Request) LoginForm { + lf := NewLoginForm() + + username := r.FormValue(lf.Username.Name) + if username == "" { + lf.Username.Err = ErrInvalidUsername.Error() + } + lf.Username.Value = username + + password := r.FormValue(lf.Password.Name) + if password == "" { + lf.Password.Err = ErrInvalidPassword.Error() + } + lf.Password.Value = password + + return lf +} + +func postLogin(w http.ResponseWriter, r *http.Request, s *services.SessionStore) { + loginForm := templates.GetLoginForm() + if loginForm == nil { + log.Error().Msg("unable to load login form") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + lf := extractLoginForm(r) + if lf.HasErrors() { + buf := bytes.NewBufferString("") + if err := loginForm.Execute(buf, &lf); err != nil { + log.Err(err).Msg("unable to generate template") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + } + + w.WriteHeader(400) + fmt.Fprint(w, buf.String()) + return + } + + if ok := lf.ValidCredentials(); !ok { + lf.Error = ErrInvalidCredentials + + buf := bytes.NewBufferString("") + if err := loginForm.Execute(buf, &lf); err != nil { + log.Err(err).Msg("unable to generate template") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + w.WriteHeader(401) + fmt.Fprint(w, buf.String()) + return + } + + session, err := s.NewSession() + if err != nil { + log.Err(err).Msg("unable to create a new session") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + } + cookie := session.GenerateCookie() + http.SetCookie(w, cookie) + + loginSuccess := templates.GetLoginSuccess() + if loginSuccess == nil { + log.Error().Msg("unable to load login success") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + fmt.Fprint(w, loginSuccess.Tree.Root.String()) +} + +func getLogin(w http.ResponseWriter, r *http.Request, s *services.SessionStore) { + loginForm := templates.GetLoginForm() + if loginForm == nil { + log.Error().Msg("unable to load login form") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + if s.IsLogged(r) { + loginSuccess := templates.GetLoginSuccess() + if loginSuccess == nil { + log.Error().Msg("unable to load login success") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + fmt.Fprint(w, loginSuccess.Tree.Root.String()) + return + } + + buf := bytes.NewBufferString("") + if err := loginForm.Execute(buf, &LoginForm{}); err != nil { + log.Err(err).Msg("unable to generate template") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + if _, err := fmt.Fprint(w, buf); err != nil { + log.Err(err).Msg("unable to write to response") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + } +} diff --git a/handlers/login/templates/form.html.tpl b/handlers/login/templates/form.html.tpl new file mode 100644 index 0000000..295bbd3 --- /dev/null +++ b/handlers/login/templates/form.html.tpl @@ -0,0 +1,66 @@ + + + + + + + + +

Login

+
+
+
+
+ + +
+ {{ if .Username.Err }} +
{{.Username.Err}}
+ {{ end }} +
+
+
+ + +
+ {{ if .Password.Err }} +
{{.Password.Err}}
+ {{ end }} +
+
+
+ +
+
+
+
+ {{ if ne (errStr .Error) "" }} +
{{.Error | errStr}}
+ {{ end }} + + + \ No newline at end of file diff --git a/handlers/login/templates/success.html.tpl b/handlers/login/templates/success.html.tpl new file mode 100644 index 0000000..e5cdc68 --- /dev/null +++ b/handlers/login/templates/success.html.tpl @@ -0,0 +1,13 @@ + + + + +

Login

+
You're logged
+

Available urls

+ + + + \ No newline at end of file diff --git a/handlers/login/templates/templates.go b/handlers/login/templates/templates.go new file mode 100644 index 0000000..aa64c1f --- /dev/null +++ b/handlers/login/templates/templates.go @@ -0,0 +1,42 @@ +package templates + +import ( + _ "embed" + "html/template" + "sync" + + "github.com/rs/zerolog/log" +) + +//go:embed form.html.tpl +var form string + +//go:embed success.html.tpl +var success string + +var funcMap = template.FuncMap{ + "errStr": func(err error) string { + if err != nil { + return err.Error() + } + return "" + }, +} + +var GetLoginForm = sync.OnceValue[*template.Template](func() *template.Template { + tmpl, err := template.New("loginForm").Funcs(funcMap).Parse(form) + if err != nil { + log.Err(err).Msg("unable to parse login form") + return nil + } + return tmpl +}) + +var GetLoginSuccess = sync.OnceValue[*template.Template](func() *template.Template { + tmpl, err := template.New("loginSuccess").Parse(success) + if err != nil { + log.Err(err).Msg("unable to parse login success") + return nil + } + return tmpl +}) diff --git a/handlers/upload/handler.go b/handlers/upload/handler.go new file mode 100644 index 0000000..fb0c0ad --- /dev/null +++ b/handlers/upload/handler.go @@ -0,0 +1,258 @@ +package upload + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "strconv" + "strings" + + "github.com/rs/zerolog/log" + + "librapi/handlers/upload/templates" + "librapi/services" +) + +const MaxFileSize = 200 // in MB + +var ( + ErrInvalidName = errors.New("book name must not be empty") + ErrInvalidEditor = errors.New("book editor must not be empty") + ErrInvalidYear = errors.New("invalid year, unable to parse") + ErrInvalidAuthors = errors.New("must at least contains one author") + ErrFileMaxSizeReached = errors.New("max file size reached, must be <= 200MB") + ErrFileOpen = errors.New("unable to open file from form") + ErrUnauthorized = errors.New("unvalid authorization key") +) + +type StrList = []string + +type BookFile struct { + file multipart.File + Header *multipart.FileHeader +} + +func (bf *BookFile) GetFilename() string { + return bf.Header.Filename +} + +func (bf *BookFile) CheckSize() error { + if bf.Header.Size > (MaxFileSize << 20) { + return ErrFileMaxSizeReached + } + return nil +} + +type BookFieldType interface { + int | string | StrList | BookFile +} + +type BookField[T BookFieldType] struct { + Name string + Value T + Err string +} + +type BookForm struct { + Name BookField[string] + Editor BookField[string] + Authors BookField[StrList] + Year BookField[int] + Keywords BookField[StrList] + File BookField[BookFile] + Error string + Method string +} + +func NewBookForm() BookForm { + return BookForm{ + Name: BookField[string]{ + Name: "name", + }, + Editor: BookField[string]{ + Name: "editor", + }, + Authors: BookField[StrList]{ + Name: "authors", + }, + Year: BookField[int]{ + Name: "year", + }, + Keywords: BookField[StrList]{ + Name: "keywords", + }, + File: BookField[BookFile]{ + Name: "file", + }, + Method: http.MethodPost, + } +} + +func (bf *BookForm) HasErrors() bool { + return bf.Name.Err != "" || bf.Authors.Err != "" || bf.Editor.Err != "" || bf.Year.Err != "" || bf.Keywords.Err != "" || bf.File.Err != "" +} + +func (bf *BookForm) IsSuccess() bool { + return bf.Method == http.MethodPost && bf.Error == "" && !bf.HasErrors() +} + +func Handler(s *services.SessionStore) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getUploadFile(w, r) + case http.MethodPost: + postUploadFile(w, r, s) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + } +} + +func extractBookForm(r *http.Request) BookForm { + bf := NewBookForm() + + name := r.FormValue(bf.Name.Name) + if name == "" { + bf.Name.Err = ErrInvalidName.Error() + } + bf.Name.Value = name + + editor := r.FormValue(bf.Editor.Name) + if editor == "" { + bf.Editor.Err = ErrInvalidEditor.Error() + } + bf.Editor.Value = editor + + if a := r.FormValue(bf.Authors.Name); len(a) != 0 { + bf.Authors.Value = strings.Split(",", a) + } else { + bf.Authors.Err = ErrInvalidAuthors.Error() + } + + year, errParse := strconv.Atoi(r.FormValue(bf.Year.Name)) + if errParse != nil { + log.Err(errParse).Msg("unable to parse date") + bf.Year.Err = ErrInvalidYear.Error() + } + bf.Year.Value = year + + if kw := r.FormValue(bf.Keywords.Name); len(kw) != 0 { + bf.Keywords.Value = strings.Split(",", kw) + } + + file, fileh, err := r.FormFile(bf.File.Name) + if err != nil { + log.Err(err).Msg("unable to get file from form") + bf.File.Err = ErrFileOpen.Error() + return bf + } + + bf.File.Value = BookFile{ + file: file, + Header: fileh, + } + + if err := bf.File.Value.CheckSize(); err != nil { + bf.File.Err = err.Error() + } + + return bf +} + +func postUploadFile(w http.ResponseWriter, r *http.Request, s *services.SessionStore) { + uploadForm := templates.GetUploadForm() + if uploadForm == nil { + log.Error().Msg("unable to load upload form") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + if !s.IsLogged(r) { + buf := bytes.NewBufferString("") + if err := uploadForm.Execute(buf, &BookForm{Error: services.ErrUnauthorized.Error()}); err != nil { + log.Err(err).Msg("unable to generate template") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + } + + w.WriteHeader(401) + fmt.Fprint(w, buf.String()) + return + } + + buf := bytes.NewBufferString("") + bf := extractBookForm(r) + if err := uploadForm.Execute(buf, &bf); err != nil { + log.Err(err).Msg("unable to generate template") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + } + + if bf.HasErrors() { + w.WriteHeader(400) + fmt.Fprint(w, buf.String()) + return + } + + filename := bf.File.Value.GetFilename() + log.Info().Str("filename", filename).Msg("file is uploading...") + + dst, err := os.Create(filename) + if err != nil { + if err := uploadForm.Execute(buf, &BookForm{Error: "unexpected error occured while creating file"}); err != nil { + log.Err(err).Msg("unable to generate template") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + } + + w.WriteHeader(500) + fmt.Fprint(w, buf.String()) + return + } + + defer dst.Close() + + if _, err := io.Copy(dst, bf.File.Value.file); err != nil { + if err := uploadForm.Execute(buf, &BookForm{Error: "unexpected error occured while uploading file"}); err != nil { + log.Err(err).Msg("unable to generate template") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + w.WriteHeader(500) + fmt.Fprint(w, buf.String()) + return + } + + buf.Reset() + if err := uploadForm.Execute(buf, &BookForm{Method: http.MethodPost}); err != nil { + log.Err(err).Msg("unable to generate template") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + fmt.Fprint(w, buf.String()) +} + +func getUploadFile(w http.ResponseWriter, r *http.Request) { + uploadForm := templates.GetUploadForm() + if uploadForm == nil { + log.Error().Msg("unable to load upload form") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + buf := bytes.NewBufferString("") + if err := uploadForm.Execute(buf, &BookForm{}); err != nil { + log.Err(err).Msg("unable to generate template") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + if _, err := fmt.Fprint(w, buf); err != nil { + log.Err(err).Msg("unable to write to response") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + } +} diff --git a/handlers/upload/templates/form.html.tpl b/handlers/upload/templates/form.html.tpl new file mode 100644 index 0000000..986766b --- /dev/null +++ b/handlers/upload/templates/form.html.tpl @@ -0,0 +1,106 @@ + + + + + + + + +

Upload a book

+
+
+
+
+ + +
+ {{ if .Name.Err }} +
{{.Name.Err}}
+ {{ end }} +
+
+
+ + +
+ {{ if .Editor.Err }} +
{{.Editor.Err}}
+ {{ end }} +
+
+
+ + +
+ {{ if .Authors.Err }} +
{{.Authors.Err}}
+ {{ end }} +
+
+
+ + +
+ {{ if .Year.Err }} +
{{.Year.Err}}
+ {{ end }} +
+
+
+ + +
+ {{ if .Keywords.Err }} +
{{.Keywords.Err}}
+ {{ end }} +
+
+
+ + +
+
+
+
+ {{ if .File.Err }} +
{{.File.Err}}
+ {{ end }} + {{ if ne .Error "" }} +
{{.Error}}
+ {{ end }} + + + + \ No newline at end of file diff --git a/handlers/upload/templates/templates.go b/handlers/upload/templates/templates.go new file mode 100644 index 0000000..5d6e649 --- /dev/null +++ b/handlers/upload/templates/templates.go @@ -0,0 +1,46 @@ +package templates + +import ( + _ "embed" + "html/template" + "mime/multipart" + "strconv" + "strings" + "sync" + + "github.com/rs/zerolog/log" +) + +var funcMap = template.FuncMap{ + "year": func(s int) string { + if s == 0 { + return "" + } + return strconv.Itoa(s) + }, + "join": func(s []string) string { + if len(s) == 0 { + return "" + } else { + return strings.Join(s, ",") + } + }, + "filename": func(h *multipart.FileHeader) string { + if h != nil { + return h.Filename + } + return "" + }, +} + +//go:embed form.html.tpl +var form string + +var GetUploadForm = sync.OnceValue[*template.Template](func() *template.Template { + tmpl, err := template.New("uploadForm").Funcs(funcMap).Parse(form) + if err != nil { + log.Err(err).Msg("unable to parse upload form") + return nil + } + return tmpl +}) diff --git a/main.go b/main.go new file mode 100644 index 0000000..d25bafd --- /dev/null +++ b/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "librapi/server" + "librapi/services" + "os" + "os/signal" + "strconv" + "sync" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "librapi/handlers/login" + "librapi/handlers/upload" +) + +const DefaultPort = 8585 + +var APIPort = sync.OnceValue[int](func() int { + port, err := strconv.Atoi(os.Getenv("API_PORT")) + if err != nil { + log.Debug().Err(err).Msg("unable to load API_PORT") + return DefaultPort + } + return port +}) + +func initLogger() { + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr}) +} + +func main() { + initLogger() + + ctx, fnCancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt) + defer fnCancel() + + sessionStore := services.NewSessionStore(ctx) + + srv := server.NewServer( + ctx, + APIPort(), + server.NewHandler("/upload", upload.Handler(sessionStore)), + server.NewHandler("/login", login.Handler(sessionStore)), + ) + srv.Serve() + + <-srv.Done() + <-sessionStore.Done() +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..ce91e82 --- /dev/null +++ b/server/server.go @@ -0,0 +1,90 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" +) + +const ( + ServerShutdownTimeout = 10 * time.Second + ServerReadTimeout = 5 * time.Second + DefaultPort = 8888 +) + +type Handler struct { + url string + fnHandle func(http.ResponseWriter, *http.Request) +} + +func NewHandler(url string, fnHandle func(http.ResponseWriter, *http.Request)) Handler { + return Handler{url: url, fnHandle: fnHandle} +} + +type Server struct { + *http.Server + + ctx context.Context +} + +type ServerOption func() + +func NewServer(ctx context.Context, port int, handlers ...Handler) Server { + if port == 0 { + log.Warn().Int("port", DefaultPort).Msg("no port detected, set to default") + port = DefaultPort + } + + srvmux := http.NewServeMux() + for _, h := range handlers { + srvmux.HandleFunc(h.url, h.fnHandle) + } + srv := http.Server{ + Addr: ":" + strconv.Itoa(port), + Handler: srvmux, + ReadTimeout: ServerReadTimeout, + } + + server := Server{ + Server: &srv, + ctx: ctx, + } + + go func() { + <-ctx.Done() + if err := server.Stop(); err != nil { + log.Err(err).Msg("unable to stop the server correctly") + } + }() + + return server +} + +func (srv *Server) Serve() { + log.Info().Str("addr", srv.Addr).Msg("http server listening") + + if err := srv.ListenAndServe(); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + log.Err(err).Msg("error occurred while serving server") + return + } + log.Info().Msg("server stopped") + } +} + +func (srv *Server) Stop() error { + log.Info().Msg("stopping server...") + + shutdownCtx, fnCancel := context.WithTimeout(srv.ctx, ServerShutdownTimeout) + defer fnCancel() + + return srv.Shutdown(shutdownCtx) +} + +func (srv *Server) Done() <-chan struct{} { + return srv.ctx.Done() +} diff --git a/services/sessions.go b/services/sessions.go new file mode 100644 index 0000000..e5f7cf6 --- /dev/null +++ b/services/sessions.go @@ -0,0 +1,162 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "os" + "strconv" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +const defaultAPISessionExpirationDuration = 30 * time.Second + +var APISessionExpirationDuration = sync.OnceValue[time.Duration](func() time.Duration { + expirationDuration, err := strconv.Atoi(os.Getenv("API_SESSION_EXPIRATION_DURATION")) + if err != nil { + log.Debug().Err(err).Msg("unable to load API_SESSION_EXPIRATION_DURATION") + return defaultAPISessionExpirationDuration + } + return time.Duration(expirationDuration * int(time.Second)) +}) + +var APISecure = sync.OnceValue[bool](func() bool { + return os.Getenv("API_SECURE") == "true" +}) + +var ( + ErrSessionIdCollision = errors.New("sessionId collision") + ErrUnauthorized = errors.New("unauthorized") +) + +func generateSessionID() (string, error) { + sessionID := make([]byte, 32) + if _, err := rand.Read(sessionID); err != nil { + return "", err + } + + return hex.EncodeToString(sessionID), nil +} + +type Session struct { + l sync.RWMutex + sessionId string + expirationTime time.Time +} + +func (s *Session) GenerateCookie() *http.Cookie { + s.l.RLock() + defer s.l.RUnlock() + + return &http.Cookie{ + Name: "session_id", + Value: s.sessionId, + HttpOnly: true, + Secure: APISecure(), + Expires: s.expirationTime, + } +} + +type SessionStore struct { + l sync.RWMutex + + ctx context.Context + fnCancel context.CancelFunc + + sessions map[string]*Session +} + +func NewSessionStore(ctx context.Context) *SessionStore { + ctxChild, fnCancel := context.WithCancel(ctx) + + s := &SessionStore{ + ctx: ctxChild, + fnCancel: fnCancel, + sessions: map[string]*Session{}, + } + s.purgeWorker() + + return s +} + +func (s *SessionStore) purge() { + s.l.Lock() + defer s.l.Unlock() + + now := time.Now() + toDelete := []*Session{} + for _, session := range s.sessions { + if now.After(session.expirationTime) { + toDelete = append(toDelete, session) + } + } + + for _, session := range toDelete { + log.Debug().Str("sessionId", session.sessionId).Msg("purge expired session") + delete(s.sessions, session.sessionId) + } +} + +func (s *SessionStore) purgeWorker() { + ticker := time.NewTicker(10 * time.Second) + go func() { + for { + select { + case <-ticker.C: + s.purge() + case <-s.ctx.Done(): + log.Info().Msg("purge worker stopped") + ticker.Stop() + return + } + } + }() +} + +func (s *SessionStore) Stop() { + s.fnCancel() +} + +func (s *SessionStore) Done() <-chan struct{} { + return s.ctx.Done() +} + +func (s *SessionStore) NewSession() (*Session, error) { + sessionId, err := generateSessionID() + if err != nil { + log.Err(err).Msg("unable to generate sessionId") + return nil, err + } + + s.l.Lock() + defer s.l.Unlock() + + if _, ok := s.sessions[sessionId]; ok { + log.Error().Str("sessionId", sessionId).Msg("sessionId collision") + return nil, ErrSessionIdCollision + } + + now := time.Now().Add(APISessionExpirationDuration()) + session := Session{expirationTime: now, sessionId: sessionId} + s.sessions[sessionId] = &session + + return &session, nil +} + +func (s *SessionStore) IsLogged(r *http.Request) bool { + cookie, err := r.Cookie("session_id") + if err != nil { + return false + } + + s.l.RLock() + defer s.l.RUnlock() + + _, ok := s.sessions[cookie.Value] + return ok +}