diff --git a/.env.example b/.env.example index 89caa70..0dfa847 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ +# 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/README.md b/README.md index 4f27c10..326a079 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,20 @@ # 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...). +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 diff --git a/forms/forms.go b/forms/forms.go new file mode 100644 index 0000000..ba6773b --- /dev/null +++ b/forms/forms.go @@ -0,0 +1,293 @@ +package forms + +import ( + "bytes" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog/log" + + "librapi/services" +) + +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("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 + +type FormFieldType interface { + int | string | StrList | UploadFile +} + +type FormField[T FormFieldType] struct { + Name string + Value T + Err string +} + +type UploadFile struct { + filename string + content []byte + size int64 +} + +func (uf *UploadFile) GetFilename() string { + return uf.filename +} + +func (uf *UploadFile) CheckSize() error { + if uf.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() + } + 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{ + filename: fileh.Filename, + content: content, + size: fileh.Size, + } + + 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) IntoResource() *services.Resource { + bm := &services.Resource{ + Name: uf.Name.Value, + Editor: uf.Editor.Value, + Authors: uf.Authors.Value, + Year: uf.Year.Value, + Keywords: nil, + Content: bytes.NewBuffer(uf.File.Value.content), + } + + if desc := uf.Description.Value; desc != "" { + bm.Description = &desc + } + + if keywords := uf.Keywords.Value; len(keywords) > 0 { + bm.Keywords = keywords + } + + 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] + 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.Resource +} + +func SearchFormFromRequest(r *http.Request) SearchForm { + sf := NewSearchForm() + sf.Search.Value = strings.TrimSpace(r.FormValue(sf.Search.Name)) + return sf +} + +func NewSearchForm() SearchForm { + return SearchForm{ + Search: FormField[string]{ + Name: "search", + }, + Method: http.MethodPost, + } +} 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/download/handler.go b/handlers/download/handler.go new file mode 100644 index 0000000..29c6973 --- /dev/null +++ b/handlers/download/handler.go @@ -0,0 +1,57 @@ +package download + +import ( + "fmt" + "librapi/services" + "net/http" + "os" + "path/filepath" + + "github.com/rs/zerolog/log" +) + +const URL = "/download" + +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, s) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + } +} + +func getDownload(w http.ResponseWriter, r *http.Request, s 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 query param does not exist", 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(s.GetDir(), 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)) + w.Header().Set("Content-Type", "application/pdf") +} 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 946a8e9..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 } @@ -135,42 +73,35 @@ 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("") - 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..544a66e 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,66 @@ 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() - log.Info().Str("filename", filename).Msg("file is uploading...") + filename := uf.File.Value.GetFilename() + log.Info().Str("filename", filename).Msg("file uploaded") - 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) + 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") 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/main.go b/main.go index 0c12cd0..187b5f7 100644 --- a/main.go +++ b/main.go @@ -6,35 +6,61 @@ import ( "librapi/services" "os" "os/signal" + "strconv" + "sync" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" + "librapi/handlers/download" "librapi/handlers/home" "librapi/handlers/login" "librapi/handlers/upload" ) -func initLogger() { - zerolog.TimeFieldFormat = zerolog.TimeFormatUnix - log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr}) -} +const ( + defaultPort = 8585 + defaulStoreDir = "./store" + version = "0.1.0" +) + +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 main() { - initLogger() + log.Info().Str("version", version).Msg("") 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.NewStore(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)), + server.NewHandler(download.URL, download.Handler(bs)), ) srv.Serve() 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..d0a2d80 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 * int(time.Second)) +}) + 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 deleted file mode 100644 index 5e3eb44..0000000 --- a/services/book_store.go +++ /dev/null @@ -1,232 +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) -} - -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 { - 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(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(bm *BookMetadata, content io.ReadCloser) error { - bs.processing.Add(1) - defer bs.processing.Done() - - defer content.Close() - - 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 - } - 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 { - return nil, err - } - bm.Authors = authorsSlice - bms = append(bms, bm) - } - - return bms, 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() -} 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/services/store.go b/services/store.go new file mode 100644 index 0000000..7ef4f5d --- /dev/null +++ b/services/store.go @@ -0,0 +1,277 @@ +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 int + 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) + + log.Info().Str("dir", s.dir).Msg("store initialized") + 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/base.html.tpl b/templates/base.html.tpl index a5dd393..05c2997 100644 --- a/templates/base.html.tpl +++ b/templates/base.html.tpl @@ -28,6 +28,18 @@ .error { color: red; } + + .col-item { + margin: 10px; + word-wrap: break-word; + width: 200px; + } + + .col-item-large { + margin: 10px; + word-wrap: break-word; + width: 300px; + } @@ -54,7 +66,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 cf1a7b0..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.

-
No extra JS, CSS or fancy stuff. You click, you search, you found, you're happy.
-
+

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.
+
@@ -24,23 +24,42 @@ {{ if ne (errStr .Error) "" }}
{{.Error | errStr}}
{{ end }} +{{ if .Results }} + {{range .Results}} - - - - - + + + + + + {{ end }}
NameDescription Editor Authors Year
{{.Name}}
{{.Editor}}
{{.Authors | join }}
{{.Year}}
{{.Path | bookUrl}}
+
{{.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 f535538..630a8d5 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 { @@ -39,7 +53,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 { @@ -54,52 +68,32 @@ var funcMap = template.FuncMap{ } return "" }, - "bookUrl": func(path string) string { + "resourceUrl": func(path string) string { _, filename := filepath.Split(path) - return fmt.Sprintf("https://books.thegux.fr/downloads/%s", filename) + return fmt.Sprintf("%s?file=%s", download.URL, filename) + }, + "noDesc": func(desc *string) string { + if desc == nil { + return "" + } + return *desc }, } -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") @@ -110,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) } 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 @@
- +