diff --git a/.env.example b/.env.example index 89caa70..978b350 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,9 @@ API_SESSION_EXPIRATION_DURATION= # in seconds (default to 30s) API_PORT= # defaul to 8585 API_SECURE= # default to "false" -API_STORE_DIR= # default to "./store" \ No newline at end of file +API_STORE_DIR= # default to "./store" + +# use a master key if you run on production +# MEILI_MASTER_KEY= +BASEURL_MEILISEARCH=http://meilisearch:7700 +MEILI_ENV=development \ No newline at end of file diff --git a/Makefile b/Makefile index 0f018a9..3f131ed 100644 --- a/Makefile +++ b/Makefile @@ -8,5 +8,9 @@ build: lint lint: golangci-lint run --fix +# .run-meilisearch: +# docker-compose up -d meilisearch + run: lint + # while [ "`curl --insecure -s -o /dev/null -w ''%{http_code}'' http://localhost:7700/health`" != "200" ]; do sleep 2; echo "waiting..."; done go run main.go \ No newline at end of file diff --git a/go.mod b/go.mod index 2493db8..4257dac 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,13 @@ module librapi go 1.22.4 -require github.com/rs/zerolog v1.33.0 +require ( + github.com/mattn/go-sqlite3 v1.14.24 + 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 - github.com/mattn/go-sqlite3 v1.14.24 // indirect golang.org/x/sys v0.12.0 // indirect ) diff --git a/handlers/login/handler.go b/handlers/login/handler.go index 946a8e9..b3d9aba 100644 --- a/handlers/login/handler.go +++ b/handlers/login/handler.go @@ -135,7 +135,7 @@ func postLogin(w http.ResponseWriter, r *http.Request, a services.IAuthenticate) return } - cookie := session.GenerateCookie() + cookie := session.GenerateCookie(a.IsSecure()) http.SetCookie(w, cookie) buf := bytes.NewBufferString("") diff --git a/main.go b/main.go index 0c12cd0..ddbb331 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,8 @@ import ( "librapi/services" "os" "os/signal" + "strconv" + "sync" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -15,6 +17,33 @@ import ( "librapi/handlers/upload" ) +const ( + defaultPort = 8585 + defaulStoreDir = "./store" +) + +var ( + isSecure = os.Getenv("API_SECURE") == "true" + + port = sync.OnceValue[int](func() int { + port, err := strconv.Atoi(os.Getenv("API_PORT")) + if err != nil { + log.Warn().Err(err).Int("default", defaultPort).Msg("unable to load API_PORT, set to default") + return defaultPort + } + return port + }) + + storeDir = sync.OnceValue[string](func() string { + storeDir := os.Getenv("API_STORE_DIR") + if storeDir == "" { + log.Warn().Str("default", defaulStoreDir).Msg("API_STORE_DIR env var empty, set to default") + return defaulStoreDir + } + return storeDir + }) +) + func initLogger() { zerolog.TimeFieldFormat = zerolog.TimeFormatUnix log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr}) @@ -26,12 +55,12 @@ func main() { ctx, fnCancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt) defer fnCancel() - auth := services.NewAuthentication(ctx) - bs := services.NewBookStore(services.GetEnv().GetDir()) + auth := services.NewAuthentication(ctx, isSecure) + bs := services.NewBookStore(storeDir()) srv := server.NewServer( ctx, - services.GetEnv().GetPort(), + port(), server.NewHandler(home.URL, home.Handler(bs)), server.NewHandler(upload.URL, upload.Handler(auth, bs)), server.NewHandler(login.URL, login.Handler(auth)), diff --git a/server/server.go b/server/server.go index 325be6e..c23a11d 100644 --- a/server/server.go +++ b/server/server.go @@ -3,7 +3,6 @@ package server import ( "context" "errors" - "librapi/services" "net/http" "strconv" "time" @@ -34,11 +33,6 @@ type Server struct { type ServerOption func() func NewServer(ctx context.Context, port int, handlers ...Handler) Server { - if port == 0 { - log.Warn().Int("port", services.GetEnv().GetPort()).Msg("no port detected, set to default") - port = services.GetEnv().GetPort() - } - srvmux := http.NewServeMux() for _, h := range handlers { srvmux.HandleFunc(h.url, h.fnHandle) diff --git a/services/authentication.go b/services/authentication.go index 2dfaeeb..0661270 100644 --- a/services/authentication.go +++ b/services/authentication.go @@ -6,17 +6,52 @@ import ( "encoding/hex" "errors" "net/http" + "os" + "strconv" "sync" "time" "github.com/rs/zerolog/log" ) +const ( + defaultAPISessionExpirationDuration = 5 * 60 * time.Second + defaultAdminPassword = "admin" + defaultAdminUsername = "admin" +) + var ( ErrSessionIDCollision = errors.New("sessionId collision") ErrUnauthorized = errors.New("unauthorized") ) +var adminPassword = sync.OnceValue[string](func() string { + adminPassword := os.Getenv("API_ADMIN_PASSWORD") + if adminPassword == "" { + log.Error().Msg("API_ADMIN_PASSWORD env var is empty, set to default") + return defaultAdminPassword + } + return adminPassword +}) + +var adminUsername = sync.OnceValue[string](func() string { + adminUsername := os.Getenv("API_ADMIN_USERNAME") + if adminUsername == "" { + log.Error().Msg("API_ADMIN_USERNAME env var is empty, set to default") + return defaultAdminUsername + } + return adminUsername +}) + +var sessionExpirationTime = sync.OnceValue[time.Duration](func() time.Duration { + sessionExpirationDuration, err := strconv.Atoi(os.Getenv("API_SESSION_EXPIRATION_DURATION")) + if err != nil { + log.Warn().Err(err).Dur("default", defaultAPISessionExpirationDuration).Msg("unable to load API_SESSION_EXPIRATION_DURATION, set to default") + return defaultAPISessionExpirationDuration + } + return time.Duration(sessionExpirationDuration) +}) + func generateSessionID() (string, error) { sessionID := make([]byte, 32) //nolint if _, err := rand.Read(sessionID); err != nil { @@ -32,7 +67,7 @@ type Session struct { expirationTime time.Time } -func (s *Session) GenerateCookie() *http.Cookie { +func (s *Session) GenerateCookie(isSecure bool) *http.Cookie { s.l.RLock() defer s.l.RUnlock() @@ -40,7 +75,7 @@ func (s *Session) GenerateCookie() *http.Cookie { Name: "session_id", Value: s.sessionID, HttpOnly: true, - Secure: GetEnv().isSecure, + Secure: isSecure, Expires: s.expirationTime, } } @@ -48,6 +83,7 @@ func (s *Session) GenerateCookie() *http.Cookie { type IAuthenticate interface { IsLogged(r *http.Request) bool Authenticate(username, password string) (*Session, error) + IsSecure() bool } var _ IAuthenticate = (*Authentication)(nil) @@ -59,15 +95,17 @@ type Authentication struct { fnCancel context.CancelFunc sessions map[string]*Session + isSecure bool } -func NewAuthentication(ctx context.Context) *Authentication { +func NewAuthentication(ctx context.Context, isSecure bool) *Authentication { ctxChild, fnCancel := context.WithCancel(ctx) s := &Authentication{ ctx: ctxChild, fnCancel: fnCancel, sessions: map[string]*Session{}, + isSecure: isSecure, } s.purgeWorker() @@ -108,6 +146,10 @@ func (a *Authentication) purgeWorker() { }() } +func (a *Authentication) IsSecure() bool { + return a.isSecure +} + func (a *Authentication) Stop() { a.fnCancel() } @@ -117,8 +159,7 @@ func (a *Authentication) Done() <-chan struct{} { } func (a *Authentication) Authenticate(username, password string) (*Session, error) { - adminUsername, adminPassword := GetEnv().GetCredentials() - if username != adminUsername || password != adminPassword { + if username != adminUsername() || password != adminPassword() { return nil, ErrUnauthorized } @@ -136,7 +177,7 @@ func (a *Authentication) Authenticate(username, password string) (*Session, erro return nil, ErrSessionIDCollision } - now := time.Now().Add(GetEnv().GetSessionExpirationDuration()) + now := time.Now().Add(sessionExpirationTime()) session := Session{expirationTime: now, sessionID: sessionID} a.sessions[sessionID] = &session diff --git a/services/book_store.go b/services/book_store.go index 5e3eb44..f8582bf 100644 --- a/services/book_store.go +++ b/services/book_store.go @@ -174,7 +174,7 @@ func (bs *BookStore) Save(bm *BookMetadata, content io.ReadCloser) error { defer content.Close() - bm.Path = filepath.Join(GetEnv().GetDir(), bm.getFormattedName()) + bm.Path = filepath.Join(bs.dir, bm.getFormattedName()) dst, err := os.Create(bm.Path) if err != nil { diff --git a/services/environments.go b/services/environments.go deleted file mode 100644 index 36f7640..0000000 --- a/services/environments.go +++ /dev/null @@ -1,81 +0,0 @@ -package services - -import ( - "os" - "strconv" - "sync" - "time" - - "github.com/rs/zerolog/log" -) - -const ( - defaultAPISessionExpirationDuration = 5 * 60 * time.Second - defaultPort = 8585 - defaultMainDir = "./store" -) - -var env = sync.OnceValue[environment](newEnv) - -type environment struct { - adminUsername string - adminPassword string - sessionExpirationDuration time.Duration - port int - isSecure bool - storeDir string -} - -func (e environment) GetCredentials() (username, password string) { - return e.adminUsername, e.adminPassword -} - -func (e environment) GetSessionExpirationDuration() time.Duration { - return e.sessionExpirationDuration -} - -func (e environment) GetPort() int { - return e.port -} - -func (e environment) IsSecure() bool { - return e.isSecure -} - -func (e environment) GetDir() string { - return e.storeDir -} - -func newEnv() environment { - env := environment{ - adminUsername: "test", - adminPassword: "test", - isSecure: os.Getenv("API_SECURE") == "true", - } - - sessionExpirationDuration, err := strconv.Atoi(os.Getenv("API_SESSION_EXPIRATION_DURATION")) - env.sessionExpirationDuration = time.Duration(sessionExpirationDuration) - if err != nil { - log.Warn().Err(err).Dur("default", defaultAPISessionExpirationDuration).Msg("unable to load API_SESSION_EXPIRATION_DURATION, set to default") - env.sessionExpirationDuration = defaultAPISessionExpirationDuration - } - - port, err := strconv.Atoi(os.Getenv("API_PORT")) - env.port = port - if err != nil { - log.Warn().Err(err).Int("default", defaultPort).Msg("unable to load API_PORT, set to default") - env.port = defaultPort - } - - storeDir := os.Getenv("API_STORE_DIR") - if storeDir == "" { - storeDir = defaultMainDir - } - env.storeDir = storeDir - - return env -} - -func GetEnv() environment { - return env() -}