Merge branch 'feat/download' into develop
This commit is contained in:
commit
30f0aa51b6
@ -1,5 +1,5 @@
|
|||||||
API_ADMIN_USERNAME=
|
API_ADMIN_USERNAME= # default to admin (must be set on production !)
|
||||||
API_ADMIN_PASSWORD=
|
API_ADMIN_PASSWORD= # default to admin (must be set on production !)
|
||||||
|
|
||||||
API_SESSION_EXPIRATION_DURATION= # in seconds (default to 30s)
|
API_SESSION_EXPIRATION_DURATION= # in seconds (default to 30s)
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
# librapi
|
# librapi
|
||||||
|
|
||||||
A simple server to store, search and download books.
|
A simple server to store, search and download resources (books, articles, etc...).
|
||||||
293
forms/forms.go
Normal file
293
forms/forms.go
Normal 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: uint16(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
6
go.mod
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
57
handlers/download/handler.go
Normal file
57
handlers/download/handler.go
Normal 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")
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 is uploading...")
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
37
main.go
37
main.go
@ -6,15 +6,45 @@ import (
|
|||||||
"librapi/services"
|
"librapi/services"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPort = 8585
|
||||||
|
defaulStoreDir = "./store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
isSecure = os.Getenv("API_SECURE") == "true"
|
||||||
|
|
||||||
|
port = sync.OnceValue[int](func() int {
|
||||||
|
port, err := strconv.Atoi(os.Getenv("API_PORT"))
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Int("default", defaultPort).Msg("unable to load API_PORT, set to default")
|
||||||
|
return defaultPort
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
})
|
||||||
|
|
||||||
|
storeDir = sync.OnceValue[string](func() string {
|
||||||
|
storeDir := os.Getenv("API_STORE_DIR")
|
||||||
|
if storeDir == "" {
|
||||||
|
log.Warn().Str("default", defaulStoreDir).Msg("API_STORE_DIR env var empty, set to default")
|
||||||
|
return defaulStoreDir
|
||||||
|
}
|
||||||
|
return storeDir
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
func initLogger() {
|
func initLogger() {
|
||||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||||
log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||||
@ -26,15 +56,16 @@ func main() {
|
|||||||
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()
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
})
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
)
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
insert into books(name, description, editor, authors, year, keywords, path)
|
|
||||||
values (?,?,?,?,?,?,?)
|
|
||||||
@ -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(?)
|
|
||||||
|
|
||||||
275
services/store.go
Normal file
275
services/store.go
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrFileCreation = errors.New("unexpected error occurred while creating resource file")
|
||||||
|
ErrFileCopy = errors.New("unexpected error occurred while copying resource file")
|
||||||
|
)
|
||||||
|
|
||||||
|
type IStore interface {
|
||||||
|
Create(r *Resource) error
|
||||||
|
Search(value string) ([]Resource, error)
|
||||||
|
GetDir() string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ IStore = (*Store)(nil)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sqlSearchResources = `
|
||||||
|
select
|
||||||
|
r.*
|
||||||
|
from
|
||||||
|
resources r
|
||||||
|
where
|
||||||
|
lower(r.name) like lower(?)
|
||||||
|
or lower(r.description) like lower(?)
|
||||||
|
or lower(r.editor) like lower(?)
|
||||||
|
or lower(r.year) like lower(?)
|
||||||
|
union
|
||||||
|
select
|
||||||
|
r2.*
|
||||||
|
from resources r2, json_each(r2.authors)
|
||||||
|
where lower(json_each.value) like lower(?)
|
||||||
|
union
|
||||||
|
select
|
||||||
|
r3.*
|
||||||
|
from resources r3, json_each(r3.keywords)
|
||||||
|
where lower(json_each.value) like lower(?)
|
||||||
|
`
|
||||||
|
sqlInsertResource = "insert into resources(name, description, editor, authors, year, keywords, path) values (?,?,?,?,?,?,?)"
|
||||||
|
sqlCreateResourceTable = `
|
||||||
|
create table if not exists resources (
|
||||||
|
name text primary key,
|
||||||
|
description text,
|
||||||
|
editor text not null,
|
||||||
|
authors jsonb not null,
|
||||||
|
year int not null,
|
||||||
|
keywords jsonb,
|
||||||
|
path text not null
|
||||||
|
)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
func sanitizeFilename(name string) string {
|
||||||
|
return strings.ReplaceAll(
|
||||||
|
strings.ReplaceAll(
|
||||||
|
strings.ToLower(name), " ", "-",
|
||||||
|
),
|
||||||
|
"'", "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resource struct {
|
||||||
|
Name string
|
||||||
|
Description *string
|
||||||
|
Editor string
|
||||||
|
Authors []string
|
||||||
|
Year uint16
|
||||||
|
Keywords []string
|
||||||
|
Content *bytes.Buffer
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resource) getAuthors() (string, error) {
|
||||||
|
authors, err := json.Marshal(r.Authors)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to marshal authors")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(authors), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resource) getKeywords() (*string, error) {
|
||||||
|
if r.Keywords == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keywords, err := json.Marshal(r.Keywords)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to marshal keywords")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
strKeywords := string(keywords)
|
||||||
|
return &strKeywords, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resource) getFormattedName() string {
|
||||||
|
name := sanitizeFilename(r.Name)
|
||||||
|
editor := sanitizeFilename(r.Editor)
|
||||||
|
author := sanitizeFilename((r.Authors[0]))
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s_%s_%s_%d.pdf", name, editor, author, r.Year)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resource) intoStmtValues() ([]any, error) {
|
||||||
|
authors, err := r.getAuthors()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keywords, err := r.getKeywords()
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to marshal authors")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return []any{
|
||||||
|
r.Name,
|
||||||
|
r.Description,
|
||||||
|
r.Editor,
|
||||||
|
authors,
|
||||||
|
r.Year,
|
||||||
|
keywords,
|
||||||
|
r.Path,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
processing *sync.WaitGroup
|
||||||
|
dir string
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(dir string) *Store {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil { //nolint
|
||||||
|
log.Fatal().Err(err).Msg("unable to create store dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Store{
|
||||||
|
processing: &sync.WaitGroup{},
|
||||||
|
dir: dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.init(dir)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) init(dir string) {
|
||||||
|
db, err := sql.Open("sqlite3", filepath.Join(dir, "librapi.db"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("unable initialize sqlite3 database")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec(sqlCreateResourceTable); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("unable to create resources table")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.db = db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createRessource(r *Resource) error {
|
||||||
|
stmt, err := s.db.Prepare(sqlInsertResource)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to parse insert resource sql")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
values, err := r.intoStmtValues()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := stmt.Exec(values...); err != nil {
|
||||||
|
log.Err(err).Msg("unable to insert new resource")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Done() <-chan struct{} {
|
||||||
|
chDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
s.processing.Wait()
|
||||||
|
if err := s.db.Close(); err != nil {
|
||||||
|
log.Err(err).Msg("unable to close the database")
|
||||||
|
}
|
||||||
|
chDone <- struct{}{}
|
||||||
|
log.Info().Msg("resource store processing done")
|
||||||
|
}()
|
||||||
|
return chDone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetDir() string {
|
||||||
|
return s.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Create(r *Resource) error {
|
||||||
|
s.processing.Add(1)
|
||||||
|
defer s.processing.Done()
|
||||||
|
|
||||||
|
path := filepath.Join(s.dir, r.getFormattedName())
|
||||||
|
r.Path = path
|
||||||
|
|
||||||
|
dst, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg(ErrFileCreation.Error())
|
||||||
|
return ErrFileCreation
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, r.Content); err != nil {
|
||||||
|
log.Err(err).Msg(ErrFileCopy.Error())
|
||||||
|
return ErrFileCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.createRessource(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Search(value string) ([]Resource, error) {
|
||||||
|
stmt, err := s.db.Prepare(sqlSearchResources)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to parse search resource sql")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
values := []any{}
|
||||||
|
for i := 0; i <= 5; i++ {
|
||||||
|
values = append(values, fmt.Sprintf("%%%s%%", value))
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := stmt.Query(values...)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to query resources")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resources := []Resource{}
|
||||||
|
for rows.Next() {
|
||||||
|
var resource Resource
|
||||||
|
var authors string
|
||||||
|
var keyword *string
|
||||||
|
if err := rows.Scan(&resource.Name, &resource.Description, &resource.Editor, &authors, &resource.Year, &keyword, &resource.Path); err != nil {
|
||||||
|
log.Err(err).Msg("unable to scan row")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorsSlice []string
|
||||||
|
if err := json.Unmarshal([]byte(authors), &authorsSlice); err != nil {
|
||||||
|
log.Err(err).Msg("unable to unmarshal authors into slice")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resource.Authors = authorsSlice
|
||||||
|
resources = append(resources, resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources, nil
|
||||||
|
}
|
||||||
@ -28,6 +28,12 @@
|
|||||||
.error {
|
.error {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.col-item {
|
||||||
|
margin: 10px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@ -54,7 +60,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
4
templates/error.html.tpl
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<h1>Error</h1>
|
||||||
|
<div>Unexpected error occurred, try again or contact the support.</div>
|
||||||
|
{{ end }}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{{ 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="/home" method="post" enctype="multipart/form-data">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="form-item">
|
<div class="form-item">
|
||||||
@ -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">{{.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">{{.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 }}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user