From 5e805b5b4d8b1a9a45ee466ba790f070f2ff360e Mon Sep 17 00:00:00 2001 From: rmanach Date: Tue, 7 Jan 2025 10:51:54 +0100 Subject: [PATCH 01/13] remove environment --- .env.example | 7 +++- Makefile | 4 ++ go.mod | 6 ++- handlers/login/handler.go | 2 +- main.go | 35 ++++++++++++++-- server/server.go | 6 --- services/authentication.go | 53 ++++++++++++++++++++++--- services/book_store.go | 2 +- services/environments.go | 81 -------------------------------------- 9 files changed, 95 insertions(+), 101 deletions(-) delete mode 100644 services/environments.go 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() -} From ee52a1b4f234cd13e065009c2d043e09a2e80ade Mon Sep 17 00:00:00 2001 From: rmanach Date: Tue, 7 Jan 2025 11:53:14 +0100 Subject: [PATCH 02/13] add download handler --- Makefile | 4 --- handlers/download/handler.go | 47 ++++++++++++++++++++++++++++++++++++ main.go | 2 ++ services/book_store.go | 5 ++++ templates/home.html.tpl | 10 +++++--- templates/templates.go | 10 ++++++-- 6 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 handlers/download/handler.go diff --git a/Makefile b/Makefile index 3f131ed..0f018a9 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,5 @@ 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/handlers/download/handler.go b/handlers/download/handler.go new file mode 100644 index 0000000..0709cb0 --- /dev/null +++ b/handlers/download/handler.go @@ -0,0 +1,47 @@ +package download + +import ( + "fmt" + "librapi/services" + "net/http" + "path/filepath" + + "github.com/rs/zerolog/log" +) + +const URL = "/download" + +func Handler(bs services.IStore) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getDownload(w, r, bs) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + } +} + +func getDownload(w http.ResponseWriter, r *http.Request, bs services.IStore) { + queryParams := r.URL.Query() + downloadFiles, ok := queryParams["file"] + if !ok { + log.Error().Msg("file query param does not exist") + http.Error(w, "file does not exists", http.StatusBadRequest) + return + } + + if len(downloadFiles) != 1 { + log.Error().Msg("only one file is allowed to download") + http.Error(w, "only one file is allowed to download", http.StatusBadRequest) + return + } + + filename := downloadFiles[0] + filePath := filepath.Join(bs.GetStoreDir(), filename) + + http.ServeFile(w, r, filePath) + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + w.Header().Set("Content-Type", "application/pdf") +} diff --git a/main.go b/main.go index ddbb331..a871d14 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" + "librapi/handlers/download" "librapi/handlers/home" "librapi/handlers/login" "librapi/handlers/upload" @@ -64,6 +65,7 @@ func main() { server.NewHandler(home.URL, home.Handler(bs)), server.NewHandler(upload.URL, upload.Handler(auth, bs)), server.NewHandler(login.URL, login.Handler(auth)), + server.NewHandler(download.URL, download.Handler(bs)), ) srv.Serve() diff --git a/services/book_store.go b/services/book_store.go index f8582bf..e0e1688 100644 --- a/services/book_store.go +++ b/services/book_store.go @@ -24,6 +24,7 @@ var ( type IStore interface { Save(bm *BookMetadata, content io.ReadCloser) error Search(value string) ([]BookMetadata, error) + GetStoreDir() string } var _ IStore = (*BookStore)(nil) @@ -168,6 +169,10 @@ func (bs *BookStore) Done() <-chan struct{} { return chDone } +func (bs *BookStore) GetStoreDir() string { + return bs.dir +} + func (bs *BookStore) Save(bm *BookMetadata, content io.ReadCloser) error { bs.processing.Add(1) defer bs.processing.Done() diff --git a/templates/home.html.tpl b/templates/home.html.tpl index cf1a7b0..6ea9458 100644 --- a/templates/home.html.tpl +++ b/templates/home.html.tpl @@ -1,5 +1,5 @@ {{ define "content" }} -

A simple API to store, search and download books.

+

A simple API to store, search and download books, articles, etc...

No extra JS, CSS or fancy stuff. You click, you search, you found, you're happy.
@@ -27,17 +27,19 @@ + {{range .Results}} - + + - + - + {{ end }}
NameDescription Editor Authors Year
{{.Name}}
{{.Name}}
{{.Description | noDesc}}
{{.Editor}}
{{.Authors | join }}
{{.Authors | join }}
{{.Year}}
{{.Path | bookUrl}}
diff --git a/templates/templates.go b/templates/templates.go index f535538..af36584 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -39,7 +39,7 @@ var funcMap = template.FuncMap{ if len(s) == 0 { return "" } else { - return strings.Join(s, ",") + return strings.Join(s, ", ") } }, "filename": func(h *multipart.FileHeader) string { @@ -56,7 +56,13 @@ var funcMap = template.FuncMap{ }, "bookUrl": func(path string) string { _, filename := filepath.Split(path) - return fmt.Sprintf("https://books.thegux.fr/downloads/%s", filename) + return fmt.Sprintf("/download?file=%s", filename) + }, + "noDesc": func(desc *string) string { + if desc == nil { + return "" + } + return *desc }, } From 40e08154f6791932cffc45afd7a5e0de8a2ba9f1 Mon Sep 17 00:00:00 2001 From: rmanach Date: Tue, 7 Jan 2025 11:54:27 +0100 Subject: [PATCH 03/13] fix .env.example --- .env.example | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 978b350..6f94fa0 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,9 @@ -API_ADMIN_USERNAME= -API_ADMIN_PASSWORD= +API_ADMIN_USERNAME= # default to admin (must be set on production !) +API_ADMIN_PASSWORD= # default to admin (must be set on production !) 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" - -# 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 +API_STORE_DIR= # default to "./store" \ No newline at end of file From 708db35214fd8f892a0ca4d393548dcc04c74337 Mon Sep 17 00:00:00 2001 From: rmanach Date: Tue, 7 Jan 2025 16:55:36 +0100 Subject: [PATCH 04/13] rework template load/generation + add generic error on template error --- forms/forms.go | 247 ++++++++++++++++++++++++++++ handlers/download/handler.go | 12 +- handlers/home/handler.go | 69 ++------ handlers/login/handler.go | 125 ++++---------- handlers/upload/handler.go | 222 +++---------------------- services/book_store.go | 60 +++++-- services/sql/create_books_table.sql | 9 - services/sql/insert_book.sql | 2 - services/sql/search_books.sql | 20 --- templates/base.html.tpl | 8 +- templates/error.html.tpl | 4 + templates/home.html.tpl | 29 +++- templates/templates.go | 143 ++++++++++------ 13 files changed, 501 insertions(+), 449 deletions(-) create mode 100644 forms/forms.go delete mode 100644 services/sql/create_books_table.sql delete mode 100644 services/sql/insert_book.sql delete mode 100644 services/sql/search_books.sql create mode 100644 templates/error.html.tpl diff --git a/forms/forms.go b/forms/forms.go new file mode 100644 index 0000000..b9e73fe --- /dev/null +++ b/forms/forms.go @@ -0,0 +1,247 @@ +package forms + +import ( + "errors" + "librapi/services" + "mime/multipart" + "net/http" + "strconv" + "strings" + + "github.com/rs/zerolog/log" +) + +const MaxFileSize = 200 // in MB + +var ( + ErrInvalidUsername = errors.New("username must not be empty") + ErrInvalidPassword = errors.New("password must not be empty") + ErrInvalidCredentials = errors.New("bad credentials") + + 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") +) + +type StrList = []string + +type FormFieldType interface { + int | string | StrList | UploadFile +} + +type FormField[T FormFieldType] struct { + Name string + Value T + Err string +} + +type UploadFile struct { + File multipart.File + Header *multipart.FileHeader +} + +func (uf *UploadFile) GetFilename() string { + return uf.Header.Filename +} + +func (uf *UploadFile) CheckSize() error { + if uf.Header.Size > (MaxFileSize << 20) { + return ErrFileMaxSizeReached + } + return nil +} + +type UploadForm struct { + Name FormField[string] + Description FormField[string] + Editor FormField[string] + Authors FormField[StrList] + Year FormField[int] + Keywords FormField[StrList] + File FormField[UploadFile] + Error string + Method string +} + +func UploadFormFromRequest(r *http.Request) UploadForm { + uf := NewUploadForm() + + name := r.FormValue(uf.Name.Name) + if name == "" { + uf.Name.Err = ErrInvalidName.Error() + } + uf.Name.Value = name + + uf.Description.Value = r.FormValue(uf.Description.Name) + + editor := r.FormValue(uf.Editor.Name) + if editor == "" { + uf.Editor.Err = ErrInvalidEditor.Error() + } + uf.Editor.Value = editor + + if a := r.FormValue(uf.Authors.Name); a != "" { + uf.Authors.Value = strings.Split(a, ",") + } else { + uf.Authors.Err = ErrInvalidAuthors.Error() + } + + year, errParse := strconv.Atoi(r.FormValue(uf.Year.Name)) + if errParse != nil { + log.Err(errParse).Msg("unable to parse date") + uf.Year.Err = ErrInvalidYear.Error() + } + uf.Year.Value = year + + if kw := r.FormValue(uf.Keywords.Name); kw != "" { + uf.Keywords.Value = strings.Split(kw, ",") + } + + file, fileh, err := r.FormFile(uf.File.Name) + if err != nil { + log.Err(err).Msg("unable to get file from form") + uf.File.Err = ErrFileOpen.Error() + return uf + } + + uf.File.Value = UploadFile{ + File: file, + Header: fileh, + } + + if err := uf.File.Value.CheckSize(); err != nil { + uf.File.Err = err.Error() + } + + return uf +} + +func NewUploadForm() UploadForm { + return UploadForm{ + Name: FormField[string]{ + Name: "name", + }, + Description: FormField[string]{ + Name: "description", + }, + Editor: FormField[string]{ + Name: "editor", + }, + Authors: FormField[StrList]{ + Name: "authors", + }, + Year: FormField[int]{ + Name: "year", + }, + Keywords: FormField[StrList]{ + Name: "keywords", + }, + File: FormField[UploadFile]{ + Name: "file", + }, + Method: http.MethodPost, + } +} + +func (uf *UploadForm) HasErrors() bool { + return uf.Name.Err != "" || + uf.Authors.Err != "" || + uf.Editor.Err != "" || + uf.Year.Err != "" || + uf.Keywords.Err != "" || + uf.File.Err != "" +} + +func (uf *UploadForm) IsSuccess() bool { + return uf.Method == http.MethodPost && uf.Error == "" && !uf.HasErrors() +} + +func (uf *UploadForm) IntoMetadata() *services.BookMetadata { + bm := &services.BookMetadata{ + Name: uf.Name.Value, + Editor: uf.Editor.Value, + Authors: uf.Authors.Value, + Year: uint16(uf.Year.Value), + Keywords: nil, + } + + if desc := uf.Description.Value; desc != "" { + bm.Description = &desc + } + + if keywords := uf.Keywords.Value; len(keywords) > 0 { + bm.Keywords = keywords + } + + return bm +} + +type LoginForm struct { + Username FormField[string] + Password FormField[string] + Error error + Method string +} + +func NewLoginForm() LoginForm { + return LoginForm{ + Username: FormField[string]{ + Name: "username", + }, + Password: FormField[string]{ + Name: "password", + }, + Method: http.MethodPost, + } +} + +func LoginFormFromRequest(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 (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() +} + +type SearchForm struct { + Search FormField[string] + Error error + Method string + Results []services.BookMetadata +} + +func SearchFormFromRequest(r *http.Request) SearchForm { + sf := NewSearchForm() + sf.Search.Value = r.FormValue(sf.Search.Name) + return sf +} + +func NewSearchForm() SearchForm { + return SearchForm{ + Search: FormField[string]{ + Name: "search", + }, + Method: http.MethodPost, + } +} diff --git a/handlers/download/handler.go b/handlers/download/handler.go index 0709cb0..ddeb69b 100644 --- a/handlers/download/handler.go +++ b/handlers/download/handler.go @@ -4,6 +4,7 @@ import ( "fmt" "librapi/services" "net/http" + "os" "path/filepath" "github.com/rs/zerolog/log" @@ -27,7 +28,7 @@ func getDownload(w http.ResponseWriter, r *http.Request, bs services.IStore) { downloadFiles, ok := queryParams["file"] if !ok { log.Error().Msg("file query param does not exist") - http.Error(w, "file does not exists", http.StatusBadRequest) + http.Error(w, "file query param does not exist", http.StatusBadRequest) return } @@ -40,6 +41,15 @@ func getDownload(w http.ResponseWriter, r *http.Request, bs services.IStore) { filename := downloadFiles[0] filePath := filepath.Join(bs.GetStoreDir(), filename) + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + http.Error(w, "file does not exist", http.StatusInternalServerError) + return + } + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + http.ServeFile(w, r, filePath) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) diff --git a/handlers/home/handler.go b/handlers/home/handler.go index b7d922d..0f0a920 100644 --- a/handlers/home/handler.go +++ b/handlers/home/handler.go @@ -1,39 +1,18 @@ package home import ( - "bytes" "fmt" - "librapi/services" - "librapi/templates" "net/http" "github.com/rs/zerolog/log" + + "librapi/forms" + "librapi/services" + "librapi/templates" ) const URL = "/" -type SearchField struct { - Name string - Value string - Err string -} - -type SearchForm struct { - Search SearchField - Error error - Method string - Results []services.BookMetadata -} - -func NewSearchForm() SearchForm { - return SearchForm{ - Search: SearchField{ - Name: "search", - }, - Method: http.MethodPost, - } -} - func Handler(bs services.IStore) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -48,52 +27,38 @@ func Handler(bs services.IStore) func(http.ResponseWriter, *http.Request) { } func getHome(w http.ResponseWriter, _ *http.Request) { - home := templates.GetHome() - - buf := bytes.NewBufferString("") - if err := home.Execute(buf, &SearchForm{}); err != nil { - log.Err(err).Msg("unable to generate template") - http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + tmpl, err := templates.ExecuteHomeTmpl(&forms.SearchForm{}, w) + if err != nil { + log.Err(err).Msg("unable to generate home template") return } - fmt.Fprint(w, buf) -} - -func extractSearchForm(r *http.Request) SearchForm { - sf := NewSearchForm() - - sf.Search.Value = r.FormValue(sf.Search.Name) - - return sf + fmt.Fprint(w, tmpl) } func postHome(w http.ResponseWriter, r *http.Request, bs services.IStore) { - home := templates.GetHome() - buf := bytes.NewBufferString("") - - sf := extractSearchForm(r) + sf := forms.SearchFormFromRequest(r) bms, err := bs.Search(sf.Search.Value) if err != nil { sf.Error = err - if err := home.Execute(buf, sf); err != nil { - log.Err(err).Msg("unable to generate template") - http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + tmpl, err := templates.ExecuteHomeTmpl(&sf, w) + if err != nil { + log.Err(err).Msg("unable to generate home template") return } w.WriteHeader(http.StatusInternalServerError) - fmt.Fprint(w, buf.String()) + fmt.Fprint(w, tmpl) return } sf.Results = bms - if err := home.Execute(buf, sf); err != nil { - log.Err(err).Msg("unable to generate template") - http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + tmpl, err := templates.ExecuteHomeTmpl(&sf, w) + if err != nil { + log.Err(err).Msg("unable to generate home template") return } - fmt.Fprint(w, buf) + fmt.Fprint(w, tmpl) } diff --git a/handlers/login/handler.go b/handlers/login/handler.go index b3d9aba..7e0966d 100644 --- a/handlers/login/handler.go +++ b/handlers/login/handler.go @@ -1,58 +1,19 @@ package login import ( - "bytes" "errors" "fmt" "net/http" "github.com/rs/zerolog/log" + "librapi/forms" "librapi/services" "librapi/templates" ) const URL = "/login" -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 Handler(a services.IAuthenticate) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -66,49 +27,27 @@ func Handler(a services.IAuthenticate) func(http.ResponseWriter, *http.Request) } } -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, a services.IAuthenticate) { - loginForm := templates.GetLoginForm() - loginSuccess := templates.GetLoginSuccess() - if a.IsLogged(r) { - buf := bytes.NewBufferString("") - if err := loginSuccess.Execute(buf, nil); err != nil { - log.Err(err).Msg("unable to generate template") - http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + tmpl, err := templates.ExecuteLoginSuccessTmpl(w) + if err != nil { + log.Err(err).Msg("unable to generate login success template") return } - fmt.Fprint(w, buf) + fmt.Fprint(w, tmpl) return } - lf := extractLoginForm(r) + lf := forms.LoginFormFromRequest(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) + tmpl, err := templates.ExecuteLoginFormTmpl(&lf, w) + if err != nil { + log.Err(err).Msg("unable to generate login form template") + return } w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, buf.String()) + fmt.Fprint(w, tmpl) return } @@ -117,17 +56,16 @@ func postLogin(w http.ResponseWriter, r *http.Request, a services.IAuthenticate) if errors.Is(err, services.ErrUnauthorized) { log.Warn().Str("username", lf.Username.Value).Msg("bad credentials") - lf.Error = ErrInvalidCredentials + lf.Error = forms.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) + tmpl, err := templates.ExecuteLoginFormTmpl(&lf, w) + if err != nil { + log.Err(err).Msg("unable to generate login form template") return } w.WriteHeader(http.StatusUnauthorized) - fmt.Fprint(w, buf.String()) + fmt.Fprint(w, tmpl) return } @@ -138,39 +76,32 @@ func postLogin(w http.ResponseWriter, r *http.Request, a services.IAuthenticate) cookie := session.GenerateCookie(a.IsSecure()) http.SetCookie(w, cookie) - buf := bytes.NewBufferString("") - if err := loginSuccess.Execute(buf, nil); err != nil { - log.Err(err).Msg("unable to generate template") - http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + tmpl, err := templates.ExecuteLoginSuccessTmpl(w) + if err != nil { + log.Err(err).Msg("unable to generate login success template") return } - fmt.Fprint(w, buf) + fmt.Fprint(w, tmpl) } func getLogin(w http.ResponseWriter, r *http.Request, a services.IAuthenticate) { - loginForm := templates.GetLoginForm() - if a.IsLogged(r) { - loginSuccess := templates.GetLoginSuccess() - - buf := bytes.NewBufferString("") - if err := loginSuccess.Execute(buf, nil); err != nil { - log.Err(err).Msg("unable to generate template") - http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + tmpl, err := templates.ExecuteLoginSuccessTmpl(w) + if err != nil { + log.Err(err).Msg("unable to generate login success template") return } - fmt.Fprint(w, buf) + fmt.Fprint(w, tmpl) 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) + tmpl, err := templates.ExecuteLoginFormTmpl(&forms.LoginForm{}, w) + if err != nil { + log.Err(err).Msg("unable to generate login form template") return } - fmt.Fprint(w, buf) + fmt.Fprint(w, tmpl) } diff --git a/handlers/upload/handler.go b/handlers/upload/handler.go index 4322d6d..4b7d77f 100644 --- a/handlers/upload/handler.go +++ b/handlers/upload/handler.go @@ -1,129 +1,20 @@ package upload import ( - "bytes" - "errors" "fmt" - "mime/multipart" "net/http" - "strconv" - "strings" "github.com/rs/zerolog/log" + "librapi/forms" "librapi/services" "librapi/templates" ) const ( - URL = "/upload" - MaxFileSize = 200 // in MB + URL = "/upload" ) -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") -) - -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] - Description 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", - }, - Description: BookField[string]{ - Name: "description", - }, - 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 (bf *BookForm) IntoMetadata() *services.BookMetadata { - bm := &services.BookMetadata{ - Name: bf.Name.Value, - Editor: bf.Editor.Value, - Authors: bf.Authors.Value, - Year: uint16(bf.Year.Value), - Keywords: nil, - } - - if desc := bf.Description.Value; desc != "" { - bm.Description = &desc - } - - if keywords := bf.Keywords.Value; len(keywords) > 0 { - bm.Keywords = keywords - } - - return bm -} - func Handler(a services.IAuthenticate, s services.IStore) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -137,123 +28,62 @@ func Handler(a services.IAuthenticate, s services.IStore) func(http.ResponseWrit } } -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 - - bf.Description.Value = r.FormValue(bf.Description.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); a != "" { - 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); kw != "" { - 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, a services.IAuthenticate, s services.IStore) { - uploadForm := templates.GetUploadForm() - if !a.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) + tmpl, err := templates.ExecuteUploadFormTmpl(&forms.UploadForm{Error: services.ErrUnauthorized.Error()}, w) + if err != nil { + log.Err(err).Msg("unable to generate upload template") return } w.WriteHeader(http.StatusUnauthorized) - fmt.Fprint(w, buf.String()) + fmt.Fprint(w, tmpl) return } - bf := extractBookForm(r) - buf := bytes.NewBufferString("") - if err := uploadForm.Execute(buf, &bf); err != nil { - log.Err(err).Msg("unable to generate template") - http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + uf := forms.UploadFormFromRequest(r) + tmpl, err := templates.ExecuteUploadFormTmpl(&uf, w) + if err != nil { + log.Err(err).Msg("unable to generate upload template") return } - if bf.HasErrors() { + if uf.HasErrors() { w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, buf.String()) + fmt.Fprint(w, tmpl) return } - filename := bf.File.Value.GetFilename() + filename := uf.File.Value.GetFilename() log.Info().Str("filename", filename).Msg("file is uploading...") - if err := s.Save(bf.IntoMetadata(), bf.File.Value.file); err != nil { - if err := uploadForm.Execute(buf, &BookForm{Error: err.Error()}); err != nil { - log.Err(err).Msg("unable to generate template") - http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + if err := s.Save(uf.IntoMetadata(), uf.File.Value.File); err != nil { + tmpl, err := templates.ExecuteUploadFormTmpl(&uf, w) + if err != nil { + log.Err(err).Msg("unable to generate upload template") return } w.WriteHeader(http.StatusInternalServerError) - fmt.Fprint(w, buf.String()) + fmt.Fprint(w, tmpl) 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) + tmpl, err = templates.ExecuteUploadFormTmpl(&forms.UploadForm{Method: http.MethodPost}, w) + if err != nil { + log.Err(err).Msg("unable to generate upload template") return } - fmt.Fprint(w, buf.String()) + fmt.Fprint(w, tmpl) } func getUploadFile(w http.ResponseWriter, _ *http.Request) { - uploadForm := templates.GetUploadForm() - - 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) + tmpl, err := templates.ExecuteUploadFormTmpl(&forms.UploadForm{}, w) + if err != nil { + log.Err(err).Msg("unable to generate upload template") return } - fmt.Fprint(w, buf) + fmt.Fprint(w, tmpl) } diff --git a/services/book_store.go b/services/book_store.go index e0e1688..690707f 100644 --- a/services/book_store.go +++ b/services/book_store.go @@ -29,14 +29,41 @@ type IStore interface { var _ IStore = (*BookStore)(nil) -//go:embed sql/create_books_table.sql -var sqlCreateTable string - -//go:embed sql/insert_book.sql -var sqlInsertBook string - -//go:embed sql/search_books.sql -var sqlSearchBooks string +const ( + sqlSearchBooks = ` + select + b.* + from + books b + where + lower(b.name) like lower(?) + or lower(b.description) like lower(?) + or lower(b.editor) like lower(?) + or lower(b.year) like lower(?) + union + select + b2.* + from books b2, json_each(b2.authors) + where lower(json_each.value) like lower(?) + union + select + b3.* + from books b3, json_each(b3.keywords) + where lower(json_each.value) like lower(?) +` + sqlInsertBook = "insert into books(name, description, editor, authors, year, keywords, path) values (?,?,?,?,?,?,?)" + sqlCreateBookTable = ` + create table if not exists books ( + name text primary key, + description text, + editor text not null, + authors jsonb not null, + year int not null, + keywords jsonb, + path text not null + ) + ` +) type BookMetadata struct { Name string @@ -45,7 +72,7 @@ type BookMetadata struct { Authors []string Year uint16 Keywords []string - Path string + path string } func (bm *BookMetadata) getAuthors() (string, error) { @@ -98,10 +125,14 @@ func (bm *BookMetadata) intoStmtValues() ([]any, error) { authors, bm.Year, keywords, - bm.Path, + bm.path, }, nil } +func (bm *BookMetadata) GetPath() string { + return bm.path +} + type BookStore struct { processing *sync.WaitGroup dir string @@ -128,7 +159,7 @@ func (bs *BookStore) init(dir string) { log.Fatal().Err(err).Msg("unable initialize sqlite3 database") } - if _, err := db.Exec(sqlCreateTable); err != nil { + if _, err := db.Exec(sqlCreateBookTable); err != nil { log.Fatal().Err(err).Msg("unable to create books table") } @@ -179,9 +210,9 @@ func (bs *BookStore) Save(bm *BookMetadata, content io.ReadCloser) error { defer content.Close() - bm.Path = filepath.Join(bs.dir, bm.getFormattedName()) + bm.path = filepath.Join(bs.dir, bm.getFormattedName()) - dst, err := os.Create(bm.Path) + dst, err := os.Create(bm.path) if err != nil { log.Err(err).Msg(ErrFileCreation.Error()) return ErrFileCreation @@ -220,13 +251,14 @@ func (bs *BookStore) Search(value string) ([]BookMetadata, error) { var bm BookMetadata var authors string var keyword *string - if err := rows.Scan(&bm.Name, &bm.Description, &bm.Editor, &authors, &bm.Year, &keyword, &bm.Path); err != nil { + if err := rows.Scan(&bm.Name, &bm.Description, &bm.Editor, &authors, &bm.Year, &keyword, &bm.path); err != nil { log.Err(err).Msg("unable to scan row") return nil, err } var authorsSlice []string if err := json.Unmarshal([]byte(authors), &authorsSlice); err != nil { + log.Err(err).Msg("unable to unmarshal authors into slice") return nil, err } bm.Authors = authorsSlice diff --git a/services/sql/create_books_table.sql b/services/sql/create_books_table.sql deleted file mode 100644 index ec0c1ba..0000000 --- a/services/sql/create_books_table.sql +++ /dev/null @@ -1,9 +0,0 @@ -create table if not exists books ( - name text primary key, - description text, - editor text not null, - authors jsonb not null, - year int not null, - keywords jsonb, - path text not null -) \ No newline at end of file diff --git a/services/sql/insert_book.sql b/services/sql/insert_book.sql deleted file mode 100644 index 685ec31..0000000 --- a/services/sql/insert_book.sql +++ /dev/null @@ -1,2 +0,0 @@ -insert into books(name, description, editor, authors, year, keywords, path) -values (?,?,?,?,?,?,?) \ No newline at end of file diff --git a/services/sql/search_books.sql b/services/sql/search_books.sql deleted file mode 100644 index abdaccc..0000000 --- a/services/sql/search_books.sql +++ /dev/null @@ -1,20 +0,0 @@ -select - b.* -from - books b -where - lower(b.name) like lower(?) - or lower(b.description) like lower(?) - or lower(b.editor) like lower(?) - or lower(b.year) like lower(?) -union -select - b2.* -from books b2, json_each(b2.authors) -where lower(json_each.value) like lower(?) -union -select - b3.* -from books b3, json_each(b3.keywords) -where lower(json_each.value) like lower(?) - diff --git a/templates/base.html.tpl b/templates/base.html.tpl index a5dd393..e26e346 100644 --- a/templates/base.html.tpl +++ b/templates/base.html.tpl @@ -28,6 +28,12 @@ .error { color: red; } + + .col-item { + margin: 10px; + word-wrap: break-word; + width: 200px; + } @@ -54,7 +60,7 @@ \ No newline at end of file diff --git a/templates/error.html.tpl b/templates/error.html.tpl new file mode 100644 index 0000000..dbd0e47 --- /dev/null +++ b/templates/error.html.tpl @@ -0,0 +1,4 @@ +{{ define "content" }} +

Error

+
Unexpected error occurred, try again or contact the support.
+{{ end }} \ No newline at end of file diff --git a/templates/home.html.tpl b/templates/home.html.tpl index 6ea9458..69fcded 100644 --- a/templates/home.html.tpl +++ b/templates/home.html.tpl @@ -24,6 +24,7 @@ {{ if ne (errStr .Error) "" }}
{{.Error | errStr}}
{{ end }} +{{ if .Results }} @@ -34,15 +35,31 @@ {{range .Results}} - - - - - - + + + + + + {{ end }}
Name
{{.Name}}
{{.Description | noDesc}}
{{.Editor}}
{{.Authors | join }}
{{.Year}}
+
{{.Name}}
+
+
{{.Description | noDesc}}
+
+
{{.Editor}}
+
+
{{.Authors | join }}
+
+
{{.Year}}
+
+
+ Download +
+
+
{{len .Results}} results found
+{{ end }} {{ end }} \ No newline at end of file diff --git a/templates/templates.go b/templates/templates.go index af36584..3a380cf 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -1,32 +1,46 @@ package templates import ( + "bytes" _ "embed" "fmt" "html/template" "mime/multipart" + "net/http" "path/filepath" "strconv" "strings" - "sync" "github.com/rs/zerolog/log" + + "librapi/handlers/download" ) -//go:embed base.html.tpl -var base string +var ( + //go:embed base.html.tpl + base string -//go:embed login/login_form.html.tpl -var loginForm string + //go:embed login/login_form.html.tpl + loginForm string -//go:embed login/login_success.html.tpl -var loginSuccess string + //go:embed login/login_success.html.tpl + loginSuccess string -//go:embed upload_form.html.tpl -var uploadForm string + //go:embed upload_form.html.tpl + uploadForm string -//go:embed home.html.tpl -var home string + //go:embed home.html.tpl + home string + + //go:embed error.html.tpl + errorBase string + + loginFormTmpl = loadLoginFormTmpl() + loginSuccessTmpl = loadLoginSuccessTmpl() + errTmpl = loadErrorTmpl() + homeTmpl = loadHomeTmpl() + uploadFormTmpl = loadUploadFormTmpl() +) var funcMap = template.FuncMap{ "year": func(s int) string { @@ -56,7 +70,7 @@ var funcMap = template.FuncMap{ }, "bookUrl": func(path string) string { _, filename := filepath.Split(path) - return fmt.Sprintf("/download?file=%s", filename) + return fmt.Sprintf("%s?file=%s", download.URL, filename) }, "noDesc": func(desc *string) string { if desc == nil { @@ -66,46 +80,20 @@ var funcMap = template.FuncMap{ }, } -var homeTmpl = sync.OnceValue[*template.Template](func() *template.Template { +func loadErrorTmpl() *template.Template { baseTmpl, err := template.New("base").Parse(base) if err != nil { log.Fatal().Err(err).Msg("unable to parse base tmpl") } - if _, err := baseTmpl.New("home").Funcs(funcMap).Parse(home); err != nil { - log.Fatal().Err(err).Msg("unable to parse home tmpl") + if _, err := baseTmpl.New("error").Funcs(funcMap).Parse(errorBase); err != nil { + log.Fatal().Err(err).Msg("unable to parse error base tmpl") } return baseTmpl -}) +} -var uploadFormTmpl = sync.OnceValue[*template.Template](func() *template.Template { - baseTmpl, err := template.New("base").Parse(base) - if err != nil { - log.Fatal().Err(err).Msg("unable to parse base tmpl") - } - - if _, err := baseTmpl.New("uploadForm").Funcs(funcMap).Parse(uploadForm); err != nil { - log.Fatal().Err(err).Msg("unable to parse upload tmpl") - } - - return baseTmpl -}) - -var loginFormTmpl = sync.OnceValue[*template.Template](func() *template.Template { - baseTmpl, err := template.New("base").Parse(base) - if err != nil { - log.Fatal().Err(err).Msg("unable to parse base tmpl") - } - - if _, err := baseTmpl.New("loginForm").Funcs(funcMap).Parse(loginForm); err != nil { - log.Fatal().Err(err).Msg("unable to parse login tmpl") - } - - return baseTmpl -}) - -var loginSuccessTmpl = sync.OnceValue[*template.Template](func() *template.Template { +func loadLoginSuccessTmpl() *template.Template { baseTmpl, err := template.New("base").Parse(base) if err != nil { log.Fatal().Err(err).Msg("unable to parse base tmpl") @@ -116,20 +104,73 @@ var loginSuccessTmpl = sync.OnceValue[*template.Template](func() *template.Templ } return baseTmpl -}) +} + +func loadLoginFormTmpl() *template.Template { + baseTmpl, err := template.New("base").Parse(base) + if err != nil { + log.Fatal().Err(err).Msg("unable to parse base tmpl") + } + + if _, err := baseTmpl.New("loginForm").Funcs(funcMap).Parse(loginForm); err != nil { + log.Fatal().Err(err).Msg("unable to parse login form tmpl") + } + + return baseTmpl +} + +func loadHomeTmpl() *template.Template { + baseTmpl, err := template.New("base").Parse(base) + if err != nil { + log.Fatal().Err(err).Msg("unable to parse base tmpl") + } + + if _, err := baseTmpl.New("home").Funcs(funcMap).Parse(home); err != nil { + log.Fatal().Err(err).Msg("unable to parse home tmpl") + } + + return baseTmpl +} + +func loadUploadFormTmpl() *template.Template { + baseTmpl, err := template.New("base").Parse(base) + if err != nil { + log.Fatal().Err(err).Msg("unable to parse base tmpl") + } + + if _, err := baseTmpl.New("uploadform").Funcs(funcMap).Parse(uploadForm); err != nil { + log.Fatal().Err(err).Msg("unable to parse upload form tmpl") + } + + return baseTmpl +} func GetHome() *template.Template { - return homeTmpl() + return homeTmpl } -func GetLoginForm() *template.Template { - return loginFormTmpl() +func executeTmpl(tmpl *template.Template, form any, w http.ResponseWriter) (string, error) { + buf := bytes.NewBufferString("") + if err := tmpl.Execute(buf, form); err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, errTmpl) + return "", err + } + return buf.String(), nil } -func GetLoginSuccess() *template.Template { - return loginSuccessTmpl() +func ExecuteLoginSuccessTmpl(w http.ResponseWriter) (string, error) { + return executeTmpl(loginSuccessTmpl, nil, w) } -func GetUploadForm() *template.Template { - return uploadFormTmpl() +func ExecuteLoginFormTmpl(form any, w http.ResponseWriter) (string, error) { + return executeTmpl(loginFormTmpl, form, w) +} + +func ExecuteHomeTmpl(form any, w http.ResponseWriter) (string, error) { + return executeTmpl(homeTmpl, form, w) +} + +func ExecuteUploadFormTmpl(form any, w http.ResponseWriter) (string, error) { + return executeTmpl(uploadFormTmpl, form, w) } From 60111911da31142f899cf11c8c76181ec16c1874 Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 8 Jan 2025 10:55:27 +0100 Subject: [PATCH 05/13] restructuration --- README.md | 2 +- forms/forms.go | 74 +++++++-- handlers/download/handler.go | 8 +- handlers/upload/handler.go | 6 +- main.go | 2 +- services/book_store.go | 269 -------------------------------- services/store.go | 275 +++++++++++++++++++++++++++++++++ templates/home.html.tpl | 4 +- templates/templates.go | 2 +- templates/upload_form.html.tpl | 4 +- 10 files changed, 351 insertions(+), 295 deletions(-) delete mode 100644 services/book_store.go create mode 100644 services/store.go diff --git a/README.md b/README.md index 4f27c10..dc1d3ea 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # librapi -A simple server to store, search and download books. \ No newline at end of file +A simple server to store, search and download resources (books, articles, etc...). \ No newline at end of file diff --git a/forms/forms.go b/forms/forms.go index b9e73fe..9a27f32 100644 --- a/forms/forms.go +++ b/forms/forms.go @@ -1,14 +1,17 @@ package forms import ( + "bytes" "errors" - "librapi/services" - "mime/multipart" + "io" "net/http" "strconv" "strings" + "time" "github.com/rs/zerolog/log" + + "librapi/services" ) const MaxFileSize = 200 // in MB @@ -18,12 +21,14 @@ var ( ErrInvalidPassword = errors.New("password must not be empty") ErrInvalidCredentials = errors.New("bad credentials") - ErrInvalidName = errors.New("book name must not be empty") - ErrInvalidEditor = errors.New("book editor must not be empty") + ErrInvalidName = errors.New("resource name must not be empty") + ErrInvalidEditor = errors.New("resource editor must not be empty") ErrInvalidYear = errors.New("invalid year, unable to parse") + ErrInvalidYearRange = errors.New("invalid year, can't be greater than today") 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") + ErrFileUnreadable = errors.New("unable to read the file") ) type StrList = []string @@ -39,16 +44,17 @@ type FormField[T FormFieldType] struct { } type UploadFile struct { - File multipart.File - Header *multipart.FileHeader + filename string + content []byte + size int64 } func (uf *UploadFile) GetFilename() string { - return uf.Header.Filename + return uf.filename } func (uf *UploadFile) CheckSize() error { - if uf.Header.Size > (MaxFileSize << 20) { + if uf.size > (MaxFileSize << 20) { return ErrFileMaxSizeReached } return nil @@ -94,22 +100,37 @@ func UploadFormFromRequest(r *http.Request) UploadForm { log.Err(errParse).Msg("unable to parse date") uf.Year.Err = ErrInvalidYear.Error() } + if year > time.Now().Year() { + log.Error().Msg("bad date range") + uf.Year.Err = ErrInvalidYearRange.Error() + } uf.Year.Value = year if kw := r.FormValue(uf.Keywords.Name); kw != "" { uf.Keywords.Value = strings.Split(kw, ",") } + uf.sanitize() + file, fileh, err := r.FormFile(uf.File.Name) if err != nil { log.Err(err).Msg("unable to get file from form") uf.File.Err = ErrFileOpen.Error() return uf } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + log.Err(err).Msg("unable to get read file from form") + uf.File.Err = ErrFileOpen.Error() + return uf + } uf.File.Value = UploadFile{ - File: file, - Header: fileh, + filename: fileh.Filename, + content: content, + size: fileh.Size, } if err := uf.File.Value.CheckSize(); err != nil { @@ -159,13 +180,14 @@ func (uf *UploadForm) IsSuccess() bool { return uf.Method == http.MethodPost && uf.Error == "" && !uf.HasErrors() } -func (uf *UploadForm) IntoMetadata() *services.BookMetadata { - bm := &services.BookMetadata{ +func (uf *UploadForm) IntoResource() *services.Resource { + bm := &services.Resource{ Name: uf.Name.Value, Editor: uf.Editor.Value, Authors: uf.Authors.Value, Year: uint16(uf.Year.Value), Keywords: nil, + Content: bytes.NewBuffer(uf.File.Value.content), } if desc := uf.Description.Value; desc != "" { @@ -179,6 +201,30 @@ func (uf *UploadForm) IntoMetadata() *services.BookMetadata { return bm } +func (uf *UploadForm) sanitize() { + uf.Name.Value = strings.TrimSpace(uf.Name.Value) + uf.Editor.Value = strings.TrimSpace(uf.Editor.Value) + uf.Description.Value = strings.TrimSpace(uf.Description.Value) + + authors := []string{} + for _, a := range uf.Authors.Value { + if a == "" { + continue + } + authors = append(authors, strings.TrimSpace(a)) + } + uf.Authors.Value = authors + + keywords := []string{} + for _, k := range uf.Keywords.Value { + if k == "" { + continue + } + keywords = append(keywords, strings.TrimSpace(k)) + } + uf.Keywords.Value = keywords +} + type LoginForm struct { Username FormField[string] Password FormField[string] @@ -228,12 +274,12 @@ type SearchForm struct { Search FormField[string] Error error Method string - Results []services.BookMetadata + Results []services.Resource } func SearchFormFromRequest(r *http.Request) SearchForm { sf := NewSearchForm() - sf.Search.Value = r.FormValue(sf.Search.Name) + sf.Search.Value = strings.TrimSpace(r.FormValue(sf.Search.Name)) return sf } diff --git a/handlers/download/handler.go b/handlers/download/handler.go index ddeb69b..29c6973 100644 --- a/handlers/download/handler.go +++ b/handlers/download/handler.go @@ -12,18 +12,18 @@ import ( const URL = "/download" -func Handler(bs services.IStore) func(http.ResponseWriter, *http.Request) { +func Handler(s services.IStore) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - getDownload(w, r, bs) + getDownload(w, r, s) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } } -func getDownload(w http.ResponseWriter, r *http.Request, bs services.IStore) { +func getDownload(w http.ResponseWriter, r *http.Request, s services.IStore) { queryParams := r.URL.Query() downloadFiles, ok := queryParams["file"] if !ok { @@ -39,7 +39,7 @@ func getDownload(w http.ResponseWriter, r *http.Request, bs services.IStore) { } filename := downloadFiles[0] - filePath := filepath.Join(bs.GetStoreDir(), filename) + filePath := filepath.Join(s.GetDir(), filename) if _, err := os.Stat(filePath); err != nil { if os.IsNotExist(err) { diff --git a/handlers/upload/handler.go b/handlers/upload/handler.go index 4b7d77f..1291b8e 100644 --- a/handlers/upload/handler.go +++ b/handlers/upload/handler.go @@ -57,7 +57,11 @@ func postUploadFile(w http.ResponseWriter, r *http.Request, a services.IAuthenti filename := uf.File.Value.GetFilename() log.Info().Str("filename", filename).Msg("file is uploading...") - if err := s.Save(uf.IntoMetadata(), uf.File.Value.File); err != nil { + resource := uf.IntoResource() + + if err := s.Create(resource); err != nil { + uf.Error = err.Error() + tmpl, err := templates.ExecuteUploadFormTmpl(&uf, w) if err != nil { log.Err(err).Msg("unable to generate upload template") diff --git a/main.go b/main.go index a871d14..04daa9f 100644 --- a/main.go +++ b/main.go @@ -57,7 +57,7 @@ func main() { defer fnCancel() auth := services.NewAuthentication(ctx, isSecure) - bs := services.NewBookStore(storeDir()) + bs := services.NewStore(storeDir()) srv := server.NewServer( ctx, diff --git a/services/book_store.go b/services/book_store.go deleted file mode 100644 index 690707f..0000000 --- a/services/book_store.go +++ /dev/null @@ -1,269 +0,0 @@ -package services - -import ( - "database/sql" - _ "embed" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "sync" - - _ "github.com/mattn/go-sqlite3" - "github.com/rs/zerolog/log" -) - -var ( - ErrFileCreation = errors.New("unexpected error occurred while creating book file") - ErrFileCopy = errors.New("unexpected error occurred while copying book file") -) - -type IStore interface { - Save(bm *BookMetadata, content io.ReadCloser) error - Search(value string) ([]BookMetadata, error) - GetStoreDir() string -} - -var _ IStore = (*BookStore)(nil) - -const ( - sqlSearchBooks = ` - select - b.* - from - books b - where - lower(b.name) like lower(?) - or lower(b.description) like lower(?) - or lower(b.editor) like lower(?) - or lower(b.year) like lower(?) - union - select - b2.* - from books b2, json_each(b2.authors) - where lower(json_each.value) like lower(?) - union - select - b3.* - from books b3, json_each(b3.keywords) - where lower(json_each.value) like lower(?) -` - sqlInsertBook = "insert into books(name, description, editor, authors, year, keywords, path) values (?,?,?,?,?,?,?)" - sqlCreateBookTable = ` - create table if not exists books ( - name text primary key, - description text, - editor text not null, - authors jsonb not null, - year int not null, - keywords jsonb, - path text not null - ) - ` -) - -type BookMetadata struct { - Name string - Description *string - Editor string - Authors []string - Year uint16 - Keywords []string - path string -} - -func (bm *BookMetadata) getAuthors() (string, error) { - authors, err := json.Marshal(bm.Authors) - if err != nil { - log.Err(err).Msg("unable to marshal authors") - return "", err - } - return string(authors), nil -} - -func (bm *BookMetadata) getKeywords() (*string, error) { - if bm.Keywords == nil { - return nil, nil - } - - keywords, err := json.Marshal(bm.Keywords) - if err != nil { - log.Err(err).Msg("unable to marshal keywords") - return nil, err - } - strKeywords := string(keywords) - return &strKeywords, nil -} - -func (bm *BookMetadata) getFormattedName() string { - name := strings.ReplaceAll(strings.ToLower(bm.Name), " ", "-") - editor := strings.ReplaceAll(strings.ToLower(bm.Editor), " ", "-") - author := strings.ReplaceAll(strings.ToLower(bm.Authors[0]), " ", "-") - - return fmt.Sprintf("%s_%s_%s_%d.pdf", name, editor, author, bm.Year) -} - -func (bm *BookMetadata) intoStmtValues() ([]any, error) { - authors, err := bm.getAuthors() - if err != nil { - return nil, err - } - - keywords, err := bm.getKeywords() - if err != nil { - log.Err(err).Msg("unable to marshal authors") - return nil, err - } - - return []any{ - bm.Name, - bm.Description, - bm.Editor, - authors, - bm.Year, - keywords, - bm.path, - }, nil -} - -func (bm *BookMetadata) GetPath() string { - return bm.path -} - -type BookStore struct { - processing *sync.WaitGroup - dir string - db *sql.DB -} - -func NewBookStore(dir string) *BookStore { - if err := os.MkdirAll(dir, 0755); err != nil { //nolint - log.Fatal().Err(err).Msg("unable to create store dir") - } - - bs := &BookStore{ - processing: &sync.WaitGroup{}, - dir: dir, - } - - bs.init(dir) - return bs -} - -func (bs *BookStore) init(dir string) { - db, err := sql.Open("sqlite3", filepath.Join(dir, "librapi.db")) - if err != nil { - log.Fatal().Err(err).Msg("unable initialize sqlite3 database") - } - - if _, err := db.Exec(sqlCreateBookTable); err != nil { - log.Fatal().Err(err).Msg("unable to create books table") - } - - bs.db = db -} - -func (bs *BookStore) store(bm *BookMetadata) error { - stmt, err := bs.db.Prepare(sqlInsertBook) - if err != nil { - log.Err(err).Msg("unable to parse insert book sql") - return err - } - defer stmt.Close() - - values, err := bm.intoStmtValues() - if err != nil { - return err - } - - if _, err := stmt.Exec(values...); err != nil { - log.Err(err).Msg("unable to insert new book") - return err - } - - return nil -} - -func (bs *BookStore) Done() <-chan struct{} { - chDone := make(chan struct{}) - go func() { - bs.processing.Wait() - if err := bs.db.Close(); err != nil { - log.Err(err).Msg("unable to close the database") - } - chDone <- struct{}{} - log.Info().Msg("book store processing done") - }() - return chDone -} - -func (bs *BookStore) GetStoreDir() string { - return bs.dir -} - -func (bs *BookStore) Save(bm *BookMetadata, content io.ReadCloser) error { - bs.processing.Add(1) - defer bs.processing.Done() - - defer content.Close() - - bm.path = filepath.Join(bs.dir, bm.getFormattedName()) - - dst, err := os.Create(bm.path) - if err != nil { - log.Err(err).Msg(ErrFileCreation.Error()) - return ErrFileCreation - } - defer dst.Close() - - if _, err := io.Copy(dst, content); err != nil { - log.Err(err).Msg(ErrFileCopy.Error()) - return ErrFileCopy - } - - return bs.store(bm) -} - -func (bs *BookStore) Search(value string) ([]BookMetadata, error) { - stmt, err := bs.db.Prepare(sqlSearchBooks) - if err != nil { - log.Err(err).Msg("unable to parse search book sql") - return nil, err - } - defer stmt.Close() - - values := []any{} - for i := 0; i <= 5; i++ { - values = append(values, fmt.Sprintf("%%%s%%", value)) - } - - rows, err := stmt.Query(values...) - if err != nil { - log.Err(err).Msg("unable to query books") - return nil, err - } - - bms := []BookMetadata{} - for rows.Next() { - var bm BookMetadata - var authors string - var keyword *string - if err := rows.Scan(&bm.Name, &bm.Description, &bm.Editor, &authors, &bm.Year, &keyword, &bm.path); err != nil { - log.Err(err).Msg("unable to scan row") - return nil, err - } - - var authorsSlice []string - if err := json.Unmarshal([]byte(authors), &authorsSlice); err != nil { - log.Err(err).Msg("unable to unmarshal authors into slice") - return nil, err - } - bm.Authors = authorsSlice - bms = append(bms, bm) - } - - return bms, nil -} diff --git a/services/store.go b/services/store.go new file mode 100644 index 0000000..6ad1f17 --- /dev/null +++ b/services/store.go @@ -0,0 +1,275 @@ +package services + +import ( + "bytes" + "database/sql" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + _ "github.com/mattn/go-sqlite3" + "github.com/rs/zerolog/log" +) + +var ( + ErrFileCreation = errors.New("unexpected error occurred while creating resource file") + ErrFileCopy = errors.New("unexpected error occurred while copying resource file") +) + +type IStore interface { + Create(r *Resource) error + Search(value string) ([]Resource, error) + GetDir() string +} + +var _ IStore = (*Store)(nil) + +const ( + sqlSearchResources = ` + select + r.* + from + resources r + where + lower(r.name) like lower(?) + or lower(r.description) like lower(?) + or lower(r.editor) like lower(?) + or lower(r.year) like lower(?) + union + select + r2.* + from resources r2, json_each(r2.authors) + where lower(json_each.value) like lower(?) + union + select + r3.* + from resources r3, json_each(r3.keywords) + where lower(json_each.value) like lower(?) +` + sqlInsertResource = "insert into resources(name, description, editor, authors, year, keywords, path) values (?,?,?,?,?,?,?)" + sqlCreateResourceTable = ` + create table if not exists resources ( + name text primary key, + description text, + editor text not null, + authors jsonb not null, + year int not null, + keywords jsonb, + path text not null + ) + ` +) + +func sanitizeFilename(name string) string { + return strings.ReplaceAll( + strings.ReplaceAll( + strings.ToLower(name), " ", "-", + ), + "'", "", + ) +} + +type Resource struct { + Name string + Description *string + Editor string + Authors []string + Year uint16 + Keywords []string + Content *bytes.Buffer + Path string +} + +func (r *Resource) getAuthors() (string, error) { + authors, err := json.Marshal(r.Authors) + if err != nil { + log.Err(err).Msg("unable to marshal authors") + return "", err + } + return string(authors), nil +} + +func (r *Resource) getKeywords() (*string, error) { + if r.Keywords == nil { + return nil, nil + } + + keywords, err := json.Marshal(r.Keywords) + if err != nil { + log.Err(err).Msg("unable to marshal keywords") + return nil, err + } + strKeywords := string(keywords) + return &strKeywords, nil +} + +func (r *Resource) getFormattedName() string { + name := sanitizeFilename(r.Name) + editor := sanitizeFilename(r.Editor) + author := sanitizeFilename((r.Authors[0])) + + return fmt.Sprintf("%s_%s_%s_%d.pdf", name, editor, author, r.Year) +} + +func (r *Resource) intoStmtValues() ([]any, error) { + authors, err := r.getAuthors() + if err != nil { + return nil, err + } + + keywords, err := r.getKeywords() + if err != nil { + log.Err(err).Msg("unable to marshal authors") + return nil, err + } + + return []any{ + r.Name, + r.Description, + r.Editor, + authors, + r.Year, + keywords, + r.Path, + }, nil +} + +type Store struct { + processing *sync.WaitGroup + dir string + db *sql.DB +} + +func NewStore(dir string) *Store { + if err := os.MkdirAll(dir, 0755); err != nil { //nolint + log.Fatal().Err(err).Msg("unable to create store dir") + } + + s := &Store{ + processing: &sync.WaitGroup{}, + dir: dir, + } + + s.init(dir) + return s +} + +func (s *Store) init(dir string) { + db, err := sql.Open("sqlite3", filepath.Join(dir, "librapi.db")) + if err != nil { + log.Fatal().Err(err).Msg("unable initialize sqlite3 database") + } + + if _, err := db.Exec(sqlCreateResourceTable); err != nil { + log.Fatal().Err(err).Msg("unable to create resources table") + } + + s.db = db +} + +func (s *Store) createRessource(r *Resource) error { + stmt, err := s.db.Prepare(sqlInsertResource) + if err != nil { + log.Err(err).Msg("unable to parse insert resource sql") + return err + } + defer stmt.Close() + + values, err := r.intoStmtValues() + if err != nil { + return err + } + + if _, err := stmt.Exec(values...); err != nil { + log.Err(err).Msg("unable to insert new resource") + return err + } + + return nil +} + +func (s *Store) Done() <-chan struct{} { + chDone := make(chan struct{}) + go func() { + s.processing.Wait() + if err := s.db.Close(); err != nil { + log.Err(err).Msg("unable to close the database") + } + chDone <- struct{}{} + log.Info().Msg("resource store processing done") + }() + return chDone +} + +func (s *Store) GetDir() string { + return s.dir +} + +func (s *Store) Create(r *Resource) error { + s.processing.Add(1) + defer s.processing.Done() + + path := filepath.Join(s.dir, r.getFormattedName()) + r.Path = path + + dst, err := os.Create(path) + if err != nil { + log.Err(err).Msg(ErrFileCreation.Error()) + return ErrFileCreation + } + defer dst.Close() + + if _, err := io.Copy(dst, r.Content); err != nil { + log.Err(err).Msg(ErrFileCopy.Error()) + return ErrFileCopy + } + + return s.createRessource(r) +} + +func (s *Store) Search(value string) ([]Resource, error) { + stmt, err := s.db.Prepare(sqlSearchResources) + if err != nil { + log.Err(err).Msg("unable to parse search resource sql") + return nil, err + } + defer stmt.Close() + + values := []any{} + for i := 0; i <= 5; i++ { + values = append(values, fmt.Sprintf("%%%s%%", value)) + } + + rows, err := stmt.Query(values...) + if err != nil { + log.Err(err).Msg("unable to query resources") + return nil, err + } + + resources := []Resource{} + for rows.Next() { + var resource Resource + var authors string + var keyword *string + if err := rows.Scan(&resource.Name, &resource.Description, &resource.Editor, &authors, &resource.Year, &keyword, &resource.Path); err != nil { + log.Err(err).Msg("unable to scan row") + return nil, err + } + + var authorsSlice []string + if err := json.Unmarshal([]byte(authors), &authorsSlice); err != nil { + log.Err(err).Msg("unable to unmarshal authors into slice") + return nil, err + } + resource.Authors = authorsSlice + resources = append(resources, resource) + } + + return resources, nil +} diff --git a/templates/home.html.tpl b/templates/home.html.tpl index 69fcded..5b1ccc7 100644 --- a/templates/home.html.tpl +++ b/templates/home.html.tpl @@ -1,6 +1,6 @@ {{ define "content" }}

A simple API to store, search and download books, articles, etc...

-
No extra JS, CSS or fancy stuff. You click, you search, you found, you're happy.
+
No extra JS, CSS or fancy stuff. You click, you search, you download, you're happy.
@@ -52,7 +52,7 @@ diff --git a/templates/templates.go b/templates/templates.go index 3a380cf..630a8d5 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -68,7 +68,7 @@ var funcMap = template.FuncMap{ } return "" }, - "bookUrl": func(path string) string { + "resourceUrl": func(path string) string { _, filename := filepath.Split(path) return fmt.Sprintf("%s?file=%s", download.URL, filename) }, diff --git a/templates/upload_form.html.tpl b/templates/upload_form.html.tpl index e9a2c8e..3584fb8 100644 --- a/templates/upload_form.html.tpl +++ b/templates/upload_form.html.tpl @@ -1,5 +1,5 @@ {{define "content" }} -

Upload a book

+

Upload a resource

@@ -58,7 +58,7 @@
- +
From ff1bb8944903ece6663af4c5b3447e84dfde86e5 Mon Sep 17 00:00:00 2001 From: landrigun Date: Wed, 8 Jan 2025 10:32:22 +0000 Subject: [PATCH 06/13] fix int overflow --- forms/forms.go | 2 +- services/store.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/forms/forms.go b/forms/forms.go index 9a27f32..ba6773b 100644 --- a/forms/forms.go +++ b/forms/forms.go @@ -185,7 +185,7 @@ func (uf *UploadForm) IntoResource() *services.Resource { Name: uf.Name.Value, Editor: uf.Editor.Value, Authors: uf.Authors.Value, - Year: uint16(uf.Year.Value), + Year: uf.Year.Value, Keywords: nil, Content: bytes.NewBuffer(uf.File.Value.content), } diff --git a/services/store.go b/services/store.go index 6ad1f17..b25d177 100644 --- a/services/store.go +++ b/services/store.go @@ -80,7 +80,7 @@ type Resource struct { Description *string Editor string Authors []string - Year uint16 + Year int Keywords []string Content *bytes.Buffer Path string From 02b478f4f9b9d74f62928e6fc7e8d84c97f46fce Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 8 Jan 2025 11:41:07 +0100 Subject: [PATCH 07/13] fix logger + .env.example --- .env.example | 18 ++++++++++++------ main.go | 8 -------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 6f94fa0..0dfa847 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ -API_ADMIN_USERNAME= # default to admin (must be set on production !) -API_ADMIN_PASSWORD= # default to admin (must be set on production !) +# default to admin (must be set on production !) +API_ADMIN_USERNAME= +# default to admin (must be set on production !) +API_ADMIN_PASSWORD= -API_SESSION_EXPIRATION_DURATION= # in seconds (default to 30s) +# in seconds (default to 30s) +API_SESSION_EXPIRATION_DURATION= -API_PORT= # defaul to 8585 -API_SECURE= # default to "false" +# default to 8585 +API_PORT= +# default to "false" +API_SECURE= -API_STORE_DIR= # default to "./store" \ No newline at end of file +# default to "./store" +API_STORE_DIR= \ No newline at end of file diff --git a/main.go b/main.go index 04daa9f..39b635f 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( "strconv" "sync" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" "librapi/handlers/download" @@ -45,14 +44,7 @@ var ( }) ) -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() From 920d08308ab9fa550d081b2d6714d19dec00863a Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 8 Jan 2025 11:58:54 +0100 Subject: [PATCH 08/13] fix session duration expiration --- services/authentication.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/authentication.go b/services/authentication.go index 0661270..d0a2d80 100644 --- a/services/authentication.go +++ b/services/authentication.go @@ -49,7 +49,7 @@ var sessionExpirationTime = sync.OnceValue[time.Duration](func() time.Duration { log.Warn().Err(err).Dur("default", defaultAPISessionExpirationDuration).Msg("unable to load API_SESSION_EXPIRATION_DURATION, set to default") return defaultAPISessionExpirationDuration } - return time.Duration(sessionExpirationDuration) + return time.Duration(sessionExpirationDuration * int(time.Second)) }) func generateSessionID() (string, error) { From 351fc09287efb7d5bedc3eb34b385f3411e99fb5 Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 8 Jan 2025 12:14:29 +0100 Subject: [PATCH 09/13] add log --- handlers/upload/handler.go | 2 +- services/store.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/handlers/upload/handler.go b/handlers/upload/handler.go index 1291b8e..544a66e 100644 --- a/handlers/upload/handler.go +++ b/handlers/upload/handler.go @@ -55,7 +55,7 @@ func postUploadFile(w http.ResponseWriter, r *http.Request, a services.IAuthenti } filename := uf.File.Value.GetFilename() - log.Info().Str("filename", filename).Msg("file is uploading...") + log.Info().Str("filename", filename).Msg("file uploaded") resource := uf.IntoResource() diff --git a/services/store.go b/services/store.go index b25d177..7ef4f5d 100644 --- a/services/store.go +++ b/services/store.go @@ -157,6 +157,8 @@ func NewStore(dir string) *Store { } s.init(dir) + + log.Info().Str("dir", s.dir).Msg("store initialized") return s } From 41fe785c980e9e18243a1538775073943dcc61a7 Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 8 Jan 2025 13:14:02 +0100 Subject: [PATCH 10/13] fix col width --- templates/base.html.tpl | 6 ++++++ templates/home.html.tpl | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/templates/base.html.tpl b/templates/base.html.tpl index e26e346..05c2997 100644 --- a/templates/base.html.tpl +++ b/templates/base.html.tpl @@ -34,6 +34,12 @@ word-wrap: break-word; width: 200px; } + + .col-item-large { + margin: 10px; + word-wrap: break-word; + width: 300px; + } diff --git a/templates/home.html.tpl b/templates/home.html.tpl index 5b1ccc7..d0ad628 100644 --- a/templates/home.html.tpl +++ b/templates/home.html.tpl @@ -36,10 +36,10 @@ {{range .Results}} -
{{.Name}}
+
{{.Name}}
-
{{.Description | noDesc}}
+
{{.Description | noDesc}}
{{.Editor}}
From 212ca7f9573a1aefdd84d5c4410c140ad8c8ee72 Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 8 Jan 2025 13:16:21 +0100 Subject: [PATCH 11/13] fix search resource form url --- templates/home.html.tpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/home.html.tpl b/templates/home.html.tpl index d0ad628..4cffa41 100644 --- a/templates/home.html.tpl +++ b/templates/home.html.tpl @@ -1,7 +1,7 @@ {{ define "content" }}

A simple API to store, search and download books, articles, etc...

No extra JS, CSS or fancy stuff. You click, you search, you download, you're happy.
- +
From c0e3bc69d4105c528e363d132ac40a7a9f70c523 Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 8 Jan 2025 13:37:28 +0100 Subject: [PATCH 12/13] update README --- README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dc1d3ea..326a079 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,20 @@ # librapi -A simple server to store, search and download resources (books, articles, etc...). \ No newline at end of file +A simple server to store, search and download resources (books, articles, etc...). +Its uses an **sqlite3** database to register resources. + +**NOTE**: The UI is dramatically ugly with almost no CSS and no responsiveness with small screens. There's no vocation to build a beautiful UI here, just to demonstrate how easy we can build practicable UI with server side rendering using Go std lib. Ideal for back office. + +## Build +Build the binary `builds/librapi`: +```bash +make build +``` + +## Run +```bash +make run +``` +You can then access the server through `http://localhost:8585`. + +Enjoy ! \ No newline at end of file From c8830ec7b361c93c8e4912b0e312e5dac444858d Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 8 Jan 2025 14:19:58 +0100 Subject: [PATCH 13/13] bump version number --- main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/main.go b/main.go index 39b635f..187b5f7 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ import ( const ( defaultPort = 8585 defaulStoreDir = "./store" + version = "0.1.0" ) var ( @@ -45,6 +46,8 @@ var ( ) func main() { + log.Info().Str("version", version).Msg("") + ctx, fnCancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt) defer fnCancel()