From 42a31817972c2e625d0b1ea9031dfab74dfc7e14 Mon Sep 17 00:00:00 2001 From: rmanach Date: Tue, 7 Jan 2025 09:29:21 +0100 Subject: [PATCH] search for a book with a single value --- go.mod | 1 + go.sum | 2 + handlers/home/handler.go | 67 ++++++++++- handlers/upload/handler.go | 23 +++- main.go | 2 +- services/book_store.go | 173 +++++++++++++++++++++++++++- services/environments.go | 2 +- services/sql/create_books_table.sql | 9 ++ services/sql/insert_book.sql | 2 + services/sql/search_books.sql | 20 ++++ templates/home.html.tpl | 44 ++++++- templates/templates.go | 8 +- 12 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 services/sql/create_books_table.sql create mode 100644 services/sql/insert_book.sql create mode 100644 services/sql/search_books.sql diff --git a/go.mod b/go.mod index 59147c3..2493db8 100644 --- a/go.mod +++ b/go.mod @@ -7,5 +7,6 @@ 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 + github.com/mattn/go-sqlite3 v1.14.24 // indirect golang.org/x/sys v0.12.0 // indirect ) diff --git a/go.sum b/go.sum index 98afda4..ba4a4ea 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk 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/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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= diff --git a/handlers/home/handler.go b/handlers/home/handler.go index 0dcab50..b7d922d 100644 --- a/handlers/home/handler.go +++ b/handlers/home/handler.go @@ -3,6 +3,7 @@ package home import ( "bytes" "fmt" + "librapi/services" "librapi/templates" "net/http" @@ -11,11 +12,35 @@ import ( const URL = "/" -func Handler() func(http.ResponseWriter, *http.Request) { +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 { case http.MethodGet: getHome(w, r) + case http.MethodPost: + postHome(w, r, bs) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } @@ -26,7 +51,45 @@ func getHome(w http.ResponseWriter, _ *http.Request) { home := templates.GetHome() buf := bytes.NewBufferString("") - if err := home.Execute(buf, nil); err != nil { + if err := home.Execute(buf, &SearchForm{}); err != nil { + log.Err(err).Msg("unable to generate template") + http.Error(w, "unexpected error occurred", http.StatusInternalServerError) + return + } + + fmt.Fprint(w, buf) +} + +func extractSearchForm(r *http.Request) SearchForm { + sf := NewSearchForm() + + sf.Search.Value = r.FormValue(sf.Search.Name) + + return sf +} + +func postHome(w http.ResponseWriter, r *http.Request, bs services.IStore) { + home := templates.GetHome() + buf := bytes.NewBufferString("") + + sf := extractSearchForm(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) + return + } + + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, buf.String()) + 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) return diff --git a/handlers/upload/handler.go b/handlers/upload/handler.go index 6392df4..4322d6d 100644 --- a/handlers/upload/handler.go +++ b/handlers/upload/handler.go @@ -104,6 +104,26 @@ 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 { @@ -203,10 +223,11 @@ func postUploadFile(w http.ResponseWriter, r *http.Request, a services.IAuthenti filename := bf.File.Value.GetFilename() log.Info().Str("filename", filename).Msg("file is uploading...") - if err := s.Save(filename, bf.File.Value.file); err != nil { + 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) + return } w.WriteHeader(http.StatusInternalServerError) diff --git a/main.go b/main.go index a9387aa..0c12cd0 100644 --- a/main.go +++ b/main.go @@ -32,7 +32,7 @@ func main() { srv := server.NewServer( ctx, services.GetEnv().GetPort(), - server.NewHandler(home.URL, home.Handler()), + 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/services/book_store.go b/services/book_store.go index 8b97d1b..5e3eb44 100644 --- a/services/book_store.go +++ b/services/book_store.go @@ -1,12 +1,18 @@ 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" ) @@ -16,14 +22,89 @@ var ( ) type IStore interface { - Save(name string, content io.ReadCloser) error + Save(bm *BookMetadata, content io.ReadCloser) error + Search(value string) ([]BookMetadata, error) } 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 + +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 +} + type BookStore struct { processing *sync.WaitGroup dir string + db *sql.DB } func NewBookStore(dir string) *BookStore { @@ -31,29 +112,71 @@ func NewBookStore(dir string) *BookStore { log.Fatal().Err(err).Msg("unable to create store dir") } - return &BookStore{ + 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(sqlCreateTable); 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) Save(name string, content io.ReadCloser) error { +func (bs *BookStore) Save(bm *BookMetadata, content io.ReadCloser) error { bs.processing.Add(1) defer bs.processing.Done() defer content.Close() - dst, err := os.Create(filepath.Join(GetEnv().GetDir(), name)) + bm.Path = filepath.Join(GetEnv().GetDir(), bm.getFormattedName()) + + dst, err := os.Create(bm.Path) if err != nil { log.Err(err).Msg(ErrFileCreation.Error()) return ErrFileCreation @@ -65,5 +188,45 @@ func (bs *BookStore) Save(name string, content io.ReadCloser) error { return ErrFileCopy } - return nil + 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 { + return nil, err + } + bm.Authors = authorsSlice + bms = append(bms, bm) + } + + return bms, nil } diff --git a/services/environments.go b/services/environments.go index 444251b..36f7640 100644 --- a/services/environments.go +++ b/services/environments.go @@ -10,7 +10,7 @@ import ( ) const ( - defaultAPISessionExpirationDuration = 30 * time.Second + defaultAPISessionExpirationDuration = 5 * 60 * time.Second defaultPort = 8585 defaultMainDir = "./store" ) diff --git a/services/sql/create_books_table.sql b/services/sql/create_books_table.sql new file mode 100644 index 0000000..ec0c1ba --- /dev/null +++ b/services/sql/create_books_table.sql @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..685ec31 --- /dev/null +++ b/services/sql/insert_book.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..abdaccc --- /dev/null +++ b/services/sql/search_books.sql @@ -0,0 +1,20 @@ +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/home.html.tpl b/templates/home.html.tpl index f6283ad..cf1a7b0 100644 --- a/templates/home.html.tpl +++ b/templates/home.html.tpl @@ -1,4 +1,46 @@ {{ define "content" }}

A simple API to store, search and download books.

-

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 found, you're happy.
+
+
+
+
+

Search

+
+
+ +
+ {{ if .Search.Err }} +
{{.Search.Err}}
+ {{ end }} +
+
+
+ +
+
+
+
+{{ if ne (errStr .Error) "" }} +
{{.Error | errStr}}
+{{ end }} + + + + + + + + {{range .Results}} + + + + + + + + {{ end }} +
NameEditorAuthorsYear
{{.Name}}
{{.Editor}}
{{.Authors | join }}
{{.Year}}
{{.Path | bookUrl}}
+ + {{ end }} \ No newline at end of file diff --git a/templates/templates.go b/templates/templates.go index 4816557..f535538 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -2,8 +2,10 @@ package templates import ( _ "embed" + "fmt" "html/template" "mime/multipart" + "path/filepath" "strconv" "strings" "sync" @@ -52,6 +54,10 @@ var funcMap = template.FuncMap{ } return "" }, + "bookUrl": func(path string) string { + _, filename := filepath.Split(path) + return fmt.Sprintf("https://books.thegux.fr/downloads/%s", filename) + }, } var homeTmpl = sync.OnceValue[*template.Template](func() *template.Template { @@ -60,7 +66,7 @@ var homeTmpl = sync.OnceValue[*template.Template](func() *template.Template { log.Fatal().Err(err).Msg("unable to parse base tmpl") } - if _, err := baseTmpl.New("home").Parse(home); err != nil { + if _, err := baseTmpl.New("home").Funcs(funcMap).Parse(home); err != nil { log.Fatal().Err(err).Msg("unable to parse home tmpl") }