Merge branch 'release/v0.1.0'

This commit is contained in:
rmanach 2025-01-08 14:20:09 +01:00
commit 4af6fc14c5
22 changed files with 964 additions and 783 deletions

View File

@ -1,9 +1,15 @@
# default to admin (must be set on production !)
API_ADMIN_USERNAME= API_ADMIN_USERNAME=
# default to admin (must be set on production !)
API_ADMIN_PASSWORD= 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 # default to 8585
API_SECURE= # default to "false" API_PORT=
# default to "false"
API_SECURE=
API_STORE_DIR= # default to "./store" # default to "./store"
API_STORE_DIR=

View File

@ -1,3 +1,20 @@
# librapi # librapi
A simple server to store, search and download books. 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 !

293
forms/forms.go Normal file
View File

@ -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,
}
}

6
go.mod
View File

@ -2,11 +2,13 @@ module librapi
go 1.22.4 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 ( require (
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // 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 golang.org/x/sys v0.12.0 // indirect
) )

View File

@ -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")
}

View File

@ -1,39 +1,18 @@
package home package home
import ( import (
"bytes"
"fmt" "fmt"
"librapi/services"
"librapi/templates"
"net/http" "net/http"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"librapi/forms"
"librapi/services"
"librapi/templates"
) )
const URL = "/" 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) { func Handler(bs services.IStore) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
@ -48,52 +27,38 @@ func Handler(bs services.IStore) func(http.ResponseWriter, *http.Request) {
} }
func getHome(w http.ResponseWriter, _ *http.Request) { func getHome(w http.ResponseWriter, _ *http.Request) {
home := templates.GetHome() tmpl, err := templates.ExecuteHomeTmpl(&forms.SearchForm{}, w)
if err != nil {
buf := bytes.NewBufferString("") log.Err(err).Msg("unable to generate home template")
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 return
} }
fmt.Fprint(w, buf) fmt.Fprint(w, tmpl)
}
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) { func postHome(w http.ResponseWriter, r *http.Request, bs services.IStore) {
home := templates.GetHome() sf := forms.SearchFormFromRequest(r)
buf := bytes.NewBufferString("")
sf := extractSearchForm(r)
bms, err := bs.Search(sf.Search.Value) bms, err := bs.Search(sf.Search.Value)
if err != nil { if err != nil {
sf.Error = err sf.Error = err
if err := home.Execute(buf, sf); err != nil { tmpl, err := templates.ExecuteHomeTmpl(&sf, w)
log.Err(err).Msg("unable to generate template") if err != nil {
http.Error(w, "unexpected error occurred", http.StatusInternalServerError) log.Err(err).Msg("unable to generate home template")
return return
} }
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, buf.String()) fmt.Fprint(w, tmpl)
return return
} }
sf.Results = bms sf.Results = bms
if err := home.Execute(buf, sf); err != nil { tmpl, err := templates.ExecuteHomeTmpl(&sf, w)
log.Err(err).Msg("unable to generate template") if err != nil {
http.Error(w, "unexpected error occurred", http.StatusInternalServerError) log.Err(err).Msg("unable to generate home template")
return return
} }
fmt.Fprint(w, buf) fmt.Fprint(w, tmpl)
} }

View File

@ -1,58 +1,19 @@
package login package login
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"librapi/forms"
"librapi/services" "librapi/services"
"librapi/templates" "librapi/templates"
) )
const URL = "/login" 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) { func Handler(a services.IAuthenticate) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
switch r.Method { 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) { func postLogin(w http.ResponseWriter, r *http.Request, a services.IAuthenticate) {
loginForm := templates.GetLoginForm()
loginSuccess := templates.GetLoginSuccess()
if a.IsLogged(r) { if a.IsLogged(r) {
buf := bytes.NewBufferString("") tmpl, err := templates.ExecuteLoginSuccessTmpl(w)
if err := loginSuccess.Execute(buf, nil); err != nil { if err != nil {
log.Err(err).Msg("unable to generate template") log.Err(err).Msg("unable to generate login success template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return return
} }
fmt.Fprint(w, buf) fmt.Fprint(w, tmpl)
return return
} }
lf := extractLoginForm(r) lf := forms.LoginFormFromRequest(r)
if lf.HasErrors() { if lf.HasErrors() {
buf := bytes.NewBufferString("") tmpl, err := templates.ExecuteLoginFormTmpl(&lf, w)
if err := loginForm.Execute(buf, &lf); err != nil { if err != nil {
log.Err(err).Msg("unable to generate template") log.Err(err).Msg("unable to generate login form template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError) return
} }
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, buf.String()) fmt.Fprint(w, tmpl)
return return
} }
@ -117,17 +56,16 @@ func postLogin(w http.ResponseWriter, r *http.Request, a services.IAuthenticate)
if errors.Is(err, services.ErrUnauthorized) { if errors.Is(err, services.ErrUnauthorized) {
log.Warn().Str("username", lf.Username.Value).Msg("bad credentials") log.Warn().Str("username", lf.Username.Value).Msg("bad credentials")
lf.Error = ErrInvalidCredentials lf.Error = forms.ErrInvalidCredentials
buf := bytes.NewBufferString("") tmpl, err := templates.ExecuteLoginFormTmpl(&lf, w)
if err := loginForm.Execute(buf, &lf); err != nil { if err != nil {
log.Err(err).Msg("unable to generate template") log.Err(err).Msg("unable to generate login form template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return return
} }
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, buf.String()) fmt.Fprint(w, tmpl)
return return
} }
@ -135,42 +73,35 @@ func postLogin(w http.ResponseWriter, r *http.Request, a services.IAuthenticate)
return return
} }
cookie := session.GenerateCookie() cookie := session.GenerateCookie(a.IsSecure())
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
buf := bytes.NewBufferString("") tmpl, err := templates.ExecuteLoginSuccessTmpl(w)
if err := loginSuccess.Execute(buf, nil); err != nil { if err != nil {
log.Err(err).Msg("unable to generate template") log.Err(err).Msg("unable to generate login success template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return return
} }
fmt.Fprint(w, buf) fmt.Fprint(w, tmpl)
} }
func getLogin(w http.ResponseWriter, r *http.Request, a services.IAuthenticate) { func getLogin(w http.ResponseWriter, r *http.Request, a services.IAuthenticate) {
loginForm := templates.GetLoginForm()
if a.IsLogged(r) { if a.IsLogged(r) {
loginSuccess := templates.GetLoginSuccess() tmpl, err := templates.ExecuteLoginSuccessTmpl(w)
if err != nil {
buf := bytes.NewBufferString("") log.Err(err).Msg("unable to generate login success template")
if err := loginSuccess.Execute(buf, nil); err != nil {
log.Err(err).Msg("unable to generate template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return return
} }
fmt.Fprint(w, buf) fmt.Fprint(w, tmpl)
return return
} }
buf := bytes.NewBufferString("") tmpl, err := templates.ExecuteLoginFormTmpl(&forms.LoginForm{}, w)
if err := loginForm.Execute(buf, &LoginForm{}); err != nil { if err != nil {
log.Err(err).Msg("unable to generate template") log.Err(err).Msg("unable to generate login form template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return return
} }
fmt.Fprint(w, buf) fmt.Fprint(w, tmpl)
} }

View File

@ -1,129 +1,20 @@
package upload package upload
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"mime/multipart"
"net/http" "net/http"
"strconv"
"strings"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"librapi/forms"
"librapi/services" "librapi/services"
"librapi/templates" "librapi/templates"
) )
const ( const (
URL = "/upload" URL = "/upload"
MaxFileSize = 200 // in MB
) )
var (
ErrInvalidName = errors.New("book name must not be empty")
ErrInvalidEditor = errors.New("book editor must not be empty")
ErrInvalidYear = errors.New("invalid year, unable to parse")
ErrInvalidAuthors = errors.New("must at least contains one author")
ErrFileMaxSizeReached = errors.New("max file size reached, must be <= 200MB")
ErrFileOpen = errors.New("unable to open file from form")
)
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) { func Handler(a services.IAuthenticate, s services.IStore) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
switch r.Method { 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) { func postUploadFile(w http.ResponseWriter, r *http.Request, a services.IAuthenticate, s services.IStore) {
uploadForm := templates.GetUploadForm()
if !a.IsLogged(r) { if !a.IsLogged(r) {
buf := bytes.NewBufferString("") tmpl, err := templates.ExecuteUploadFormTmpl(&forms.UploadForm{Error: services.ErrUnauthorized.Error()}, w)
if err := uploadForm.Execute(buf, &BookForm{Error: services.ErrUnauthorized.Error()}); err != nil { if err != nil {
log.Err(err).Msg("unable to generate template") log.Err(err).Msg("unable to generate upload template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return return
} }
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, buf.String()) fmt.Fprint(w, tmpl)
return return
} }
bf := extractBookForm(r) uf := forms.UploadFormFromRequest(r)
buf := bytes.NewBufferString("") tmpl, err := templates.ExecuteUploadFormTmpl(&uf, w)
if err := uploadForm.Execute(buf, &bf); err != nil { if err != nil {
log.Err(err).Msg("unable to generate template") log.Err(err).Msg("unable to generate upload template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return return
} }
if bf.HasErrors() { if uf.HasErrors() {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, buf.String()) fmt.Fprint(w, tmpl)
return return
} }
filename := bf.File.Value.GetFilename() filename := uf.File.Value.GetFilename()
log.Info().Str("filename", filename).Msg("file is uploading...") log.Info().Str("filename", filename).Msg("file uploaded")
if err := s.Save(bf.IntoMetadata(), bf.File.Value.file); err != nil { resource := uf.IntoResource()
if err := uploadForm.Execute(buf, &BookForm{Error: err.Error()}); err != nil {
log.Err(err).Msg("unable to generate template") if err := s.Create(resource); err != nil {
http.Error(w, "unexpected error occurred", http.StatusInternalServerError) uf.Error = err.Error()
tmpl, err := templates.ExecuteUploadFormTmpl(&uf, w)
if err != nil {
log.Err(err).Msg("unable to generate upload template")
return return
} }
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, buf.String()) fmt.Fprint(w, tmpl)
return return
} }
buf.Reset() tmpl, err = templates.ExecuteUploadFormTmpl(&forms.UploadForm{Method: http.MethodPost}, w)
if err := uploadForm.Execute(buf, &BookForm{Method: http.MethodPost}); err != nil { if err != nil {
log.Err(err).Msg("unable to generate template") log.Err(err).Msg("unable to generate upload template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return return
} }
fmt.Fprint(w, buf.String()) fmt.Fprint(w, tmpl)
} }
func getUploadFile(w http.ResponseWriter, _ *http.Request) { func getUploadFile(w http.ResponseWriter, _ *http.Request) {
uploadForm := templates.GetUploadForm() tmpl, err := templates.ExecuteUploadFormTmpl(&forms.UploadForm{}, w)
if err != nil {
buf := bytes.NewBufferString("") log.Err(err).Msg("unable to generate upload template")
if err := uploadForm.Execute(buf, &BookForm{}); err != nil {
log.Err(err).Msg("unable to generate template")
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
return return
} }
fmt.Fprint(w, buf) fmt.Fprint(w, tmpl)
} }

44
main.go
View File

@ -6,35 +6,61 @@ import (
"librapi/services" "librapi/services"
"os" "os"
"os/signal" "os/signal"
"strconv"
"sync"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"librapi/handlers/download"
"librapi/handlers/home" "librapi/handlers/home"
"librapi/handlers/login" "librapi/handlers/login"
"librapi/handlers/upload" "librapi/handlers/upload"
) )
func initLogger() { const (
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix defaultPort = 8585
log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr}) 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() { func main() {
initLogger() log.Info().Str("version", version).Msg("")
ctx, fnCancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt) ctx, fnCancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt)
defer fnCancel() defer fnCancel()
auth := services.NewAuthentication(ctx) auth := services.NewAuthentication(ctx, isSecure)
bs := services.NewBookStore(services.GetEnv().GetDir()) bs := services.NewStore(storeDir())
srv := server.NewServer( srv := server.NewServer(
ctx, ctx,
services.GetEnv().GetPort(), port(),
server.NewHandler(home.URL, home.Handler(bs)), server.NewHandler(home.URL, home.Handler(bs)),
server.NewHandler(upload.URL, upload.Handler(auth, bs)), server.NewHandler(upload.URL, upload.Handler(auth, bs)),
server.NewHandler(login.URL, login.Handler(auth)), server.NewHandler(login.URL, login.Handler(auth)),
server.NewHandler(download.URL, download.Handler(bs)),
) )
srv.Serve() srv.Serve()

View File

@ -3,7 +3,6 @@ package server
import ( import (
"context" "context"
"errors" "errors"
"librapi/services"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@ -34,11 +33,6 @@ type Server struct {
type ServerOption func() type ServerOption func()
func NewServer(ctx context.Context, port int, handlers ...Handler) Server { 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() srvmux := http.NewServeMux()
for _, h := range handlers { for _, h := range handlers {
srvmux.HandleFunc(h.url, h.fnHandle) srvmux.HandleFunc(h.url, h.fnHandle)

View File

@ -6,17 +6,52 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"net/http" "net/http"
"os"
"strconv"
"sync" "sync"
"time" "time"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
const (
defaultAPISessionExpirationDuration = 5 * 60 * time.Second
defaultAdminPassword = "admin"
defaultAdminUsername = "admin"
)
var ( var (
ErrSessionIDCollision = errors.New("sessionId collision") ErrSessionIDCollision = errors.New("sessionId collision")
ErrUnauthorized = errors.New("unauthorized") 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) { func generateSessionID() (string, error) {
sessionID := make([]byte, 32) //nolint sessionID := make([]byte, 32) //nolint
if _, err := rand.Read(sessionID); err != nil { if _, err := rand.Read(sessionID); err != nil {
@ -32,7 +67,7 @@ type Session struct {
expirationTime time.Time expirationTime time.Time
} }
func (s *Session) GenerateCookie() *http.Cookie { func (s *Session) GenerateCookie(isSecure bool) *http.Cookie {
s.l.RLock() s.l.RLock()
defer s.l.RUnlock() defer s.l.RUnlock()
@ -40,7 +75,7 @@ func (s *Session) GenerateCookie() *http.Cookie {
Name: "session_id", Name: "session_id",
Value: s.sessionID, Value: s.sessionID,
HttpOnly: true, HttpOnly: true,
Secure: GetEnv().isSecure, Secure: isSecure,
Expires: s.expirationTime, Expires: s.expirationTime,
} }
} }
@ -48,6 +83,7 @@ func (s *Session) GenerateCookie() *http.Cookie {
type IAuthenticate interface { type IAuthenticate interface {
IsLogged(r *http.Request) bool IsLogged(r *http.Request) bool
Authenticate(username, password string) (*Session, error) Authenticate(username, password string) (*Session, error)
IsSecure() bool
} }
var _ IAuthenticate = (*Authentication)(nil) var _ IAuthenticate = (*Authentication)(nil)
@ -59,15 +95,17 @@ type Authentication struct {
fnCancel context.CancelFunc fnCancel context.CancelFunc
sessions map[string]*Session 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) ctxChild, fnCancel := context.WithCancel(ctx)
s := &Authentication{ s := &Authentication{
ctx: ctxChild, ctx: ctxChild,
fnCancel: fnCancel, fnCancel: fnCancel,
sessions: map[string]*Session{}, sessions: map[string]*Session{},
isSecure: isSecure,
} }
s.purgeWorker() s.purgeWorker()
@ -108,6 +146,10 @@ func (a *Authentication) purgeWorker() {
}() }()
} }
func (a *Authentication) IsSecure() bool {
return a.isSecure
}
func (a *Authentication) Stop() { func (a *Authentication) Stop() {
a.fnCancel() a.fnCancel()
} }
@ -117,8 +159,7 @@ func (a *Authentication) Done() <-chan struct{} {
} }
func (a *Authentication) Authenticate(username, password string) (*Session, error) { 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 return nil, ErrUnauthorized
} }
@ -136,7 +177,7 @@ func (a *Authentication) Authenticate(username, password string) (*Session, erro
return nil, ErrSessionIDCollision return nil, ErrSessionIDCollision
} }
now := time.Now().Add(GetEnv().GetSessionExpirationDuration()) now := time.Now().Add(sessionExpirationTime())
session := Session{expirationTime: now, sessionID: sessionID} session := Session{expirationTime: now, sessionID: sessionID}
a.sessions[sessionID] = &session a.sessions[sessionID] = &session

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
)

View File

@ -1,2 +0,0 @@
insert into books(name, description, editor, authors, year, keywords, path)
values (?,?,?,?,?,?,?)

View File

@ -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(?)

277
services/store.go Normal file
View File

@ -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
}

View File

@ -28,6 +28,18 @@
.error { .error {
color: red; color: red;
} }
.col-item {
margin: 10px;
word-wrap: break-word;
width: 200px;
}
.col-item-large {
margin: 10px;
word-wrap: break-word;
width: 300px;
}
</style> </style>
</head> </head>
@ -54,7 +66,7 @@
</body> </body>
<script type="text/javascript"> <script type="text/javascript">
{{ block "script" . }}{{ end }} {{ block "script". }} {{ end }}
</script> </script>
</html> </html>

4
templates/error.html.tpl Normal file
View File

@ -0,0 +1,4 @@
{{ define "content" }}
<h1>Error</h1>
<div>Unexpected error occurred, try again or contact the support.</div>
{{ end }}

View File

@ -1,7 +1,7 @@
{{ define "content" }} {{ define "content" }}
<h3>A simple API to store, search and download books.</h3> <h3>A simple API to store, search and download books, articles, etc...</h3>
<div>No extra JS, CSS or fancy stuff. You click, you search, you found, you're happy.</div> <div>No extra JS, CSS or fancy stuff. You click, you search, you download, you're happy.</div>
<form action="/home" method="post" enctype="multipart/form-data"> <form action="/" method="post" enctype="multipart/form-data">
<div class="main-container"> <div class="main-container">
<div class="form-item"> <div class="form-item">
<div class="form-container"> <div class="form-container">
@ -24,23 +24,42 @@
{{ if ne (errStr .Error) "" }} {{ if ne (errStr .Error) "" }}
<div class="error">{{.Error | errStr}}</div> <div class="error">{{.Error | errStr}}</div>
{{ end }} {{ end }}
{{ if .Results }}
<table> <table>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Description</th>
<th>Editor</th> <th>Editor</th>
<th>Authors</th> <th>Authors</th>
<th>Year</th> <th>Year</th>
</tr> </tr>
{{range .Results}} {{range .Results}}
<tr> <tr>
<td><div style="margin: 10px;">{{.Name}}</div></td> <td>
<td style="text-align: center;"><div style="margin: 10px;">{{.Editor}}</div></td> <div class="col-item-large">{{.Name}}</div>
<td style="text-align: center;"><div style="margin: 10px;">{{.Authors | join }}</div></td> </td>
<td><div style="margin: 20px;">{{.Year}}</div></td> <td>
<td><div style="margin: 20px;">{{.Path | bookUrl}}</div></td> <div class="col-item-large">{{.Description | noDesc}}</div>
</td>
<td style="text-align: center;">
<div class="col-item">{{.Editor}}</div>
</td>
<td style="text-align: center">
<div class="col-item">{{.Authors | join }}</div>
</td>
<td style="text-align: center">
<div class="col-item">{{.Year}}</div>
</td>
<td style="text-align: center">
<div class="col-item">
<a target="_blank" href="{{.Path | resourceUrl}}">Download</a>
</div>
</td>
</tr> </tr>
{{ end }} {{ end }}
</table> </table>
<div>{{len .Results}} results found</div>
{{ end }}
</ul> </ul>
{{ end }} {{ end }}

View File

@ -1,32 +1,46 @@
package templates package templates
import ( import (
"bytes"
_ "embed" _ "embed"
"fmt" "fmt"
"html/template" "html/template"
"mime/multipart" "mime/multipart"
"net/http"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"librapi/handlers/download"
) )
//go:embed base.html.tpl var (
var base string //go:embed base.html.tpl
base string
//go:embed login/login_form.html.tpl //go:embed login/login_form.html.tpl
var loginForm string loginForm string
//go:embed login/login_success.html.tpl //go:embed login/login_success.html.tpl
var loginSuccess string loginSuccess string
//go:embed upload_form.html.tpl //go:embed upload_form.html.tpl
var uploadForm string uploadForm string
//go:embed home.html.tpl //go:embed home.html.tpl
var home string home string
//go:embed error.html.tpl
errorBase string
loginFormTmpl = loadLoginFormTmpl()
loginSuccessTmpl = loadLoginSuccessTmpl()
errTmpl = loadErrorTmpl()
homeTmpl = loadHomeTmpl()
uploadFormTmpl = loadUploadFormTmpl()
)
var funcMap = template.FuncMap{ var funcMap = template.FuncMap{
"year": func(s int) string { "year": func(s int) string {
@ -39,7 +53,7 @@ var funcMap = template.FuncMap{
if len(s) == 0 { if len(s) == 0 {
return "" return ""
} else { } else {
return strings.Join(s, ",") return strings.Join(s, ", ")
} }
}, },
"filename": func(h *multipart.FileHeader) string { "filename": func(h *multipart.FileHeader) string {
@ -54,52 +68,32 @@ var funcMap = template.FuncMap{
} }
return "" return ""
}, },
"bookUrl": func(path string) string { "resourceUrl": func(path string) string {
_, filename := filepath.Split(path) _, 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) baseTmpl, err := template.New("base").Parse(base)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("unable to parse base tmpl") log.Fatal().Err(err).Msg("unable to parse base tmpl")
} }
if _, err := baseTmpl.New("home").Funcs(funcMap).Parse(home); err != nil { if _, err := baseTmpl.New("error").Funcs(funcMap).Parse(errorBase); err != nil {
log.Fatal().Err(err).Msg("unable to parse home tmpl") log.Fatal().Err(err).Msg("unable to parse error base tmpl")
} }
return baseTmpl return baseTmpl
}) }
var uploadFormTmpl = 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")
}
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 {
baseTmpl, err := template.New("base").Parse(base) baseTmpl, err := template.New("base").Parse(base)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("unable to parse base tmpl") 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 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 { func GetHome() *template.Template {
return homeTmpl() return homeTmpl
} }
func GetLoginForm() *template.Template { func executeTmpl(tmpl *template.Template, form any, w http.ResponseWriter) (string, error) {
return loginFormTmpl() 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 { func ExecuteLoginSuccessTmpl(w http.ResponseWriter) (string, error) {
return loginSuccessTmpl() return executeTmpl(loginSuccessTmpl, nil, w)
} }
func GetUploadForm() *template.Template { func ExecuteLoginFormTmpl(form any, w http.ResponseWriter) (string, error) {
return uploadFormTmpl() 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)
} }

View File

@ -1,5 +1,5 @@
{{define "content" }} {{define "content" }}
<h2>Upload a book</h2> <h2>Upload a resource</h2>
<form action="/upload" method="post" enctype="multipart/form-data"> <form action="/upload" method="post" enctype="multipart/form-data">
<div class="main-container"> <div class="main-container">
<div class="form-item"> <div class="form-item">
@ -58,7 +58,7 @@
</div> </div>
<div class="form-item"> <div class="form-item">
<div class="form-container"> <div class="form-container">
<input type="file" name="file" files="{{.File.Value.Header | filename}}" accept=".pdf" /> <input type="file" name="file" files="{{.File.Value.GetFilename}}" accept=".pdf" />
<button id="submit" type="submit">Upload</button> <button id="submit" type="submit">Upload</button>
</div> </div>
</div> </div>