init repo
This commit is contained in:
commit
6576f45eac
7
.env.example
Normal file
7
.env.example
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
API_ADMIN_USERNAME=
|
||||||
|
API_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
API_SESSION_EXPIRATION_DURATION= # in seconds
|
||||||
|
|
||||||
|
API_PORT=
|
||||||
|
API_SECURE= # default to "false"
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
builds
|
||||||
|
|
||||||
|
.env
|
||||||
12
Makefile
Normal file
12
Makefile
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
test:
|
||||||
|
go test ./... -race
|
||||||
|
|
||||||
|
build: lint
|
||||||
|
mkdir -p builds
|
||||||
|
go build -o builds/librapi main.go
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run --fix
|
||||||
|
|
||||||
|
run: lint
|
||||||
|
go run main.go
|
||||||
11
go.mod
Normal file
11
go.mod
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
module librapi
|
||||||
|
|
||||||
|
go 1.22.4
|
||||||
|
|
||||||
|
require github.com/rs/zerolog v1.33.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
golang.org/x/sys v0.12.0 // indirect
|
||||||
|
)
|
||||||
15
go.sum
Normal file
15
go.sum
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||||
|
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
184
handlers/login/handler.go
Normal file
184
handlers/login/handler.go
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
package login
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"librapi/handlers/login/templates"
|
||||||
|
"librapi/services"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
adminUsername = sync.OnceValue[string](func() string {
|
||||||
|
return "test"
|
||||||
|
})
|
||||||
|
|
||||||
|
adminPassword = sync.OnceValue[string](func() string {
|
||||||
|
return "test"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (lf *LoginForm) ValidCredentials() bool {
|
||||||
|
return lf.Username.Value == adminUsername() && lf.Password.Value == adminPassword()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Handler(s *services.SessionStore) func(http.ResponseWriter, *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
getLogin(w, r, s)
|
||||||
|
case http.MethodPost:
|
||||||
|
postLogin(w, r, s)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, s *services.SessionStore) {
|
||||||
|
loginForm := templates.GetLoginForm()
|
||||||
|
if loginForm == nil {
|
||||||
|
log.Error().Msg("unable to load login form")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lf := extractLoginForm(r)
|
||||||
|
if lf.HasErrors() {
|
||||||
|
buf := bytes.NewBufferString("")
|
||||||
|
if err := loginForm.Execute(buf, &lf); err != nil {
|
||||||
|
log.Err(err).Msg("unable to generate template")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(400)
|
||||||
|
fmt.Fprint(w, buf.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := lf.ValidCredentials(); !ok {
|
||||||
|
lf.Error = ErrInvalidCredentials
|
||||||
|
|
||||||
|
buf := bytes.NewBufferString("")
|
||||||
|
if err := loginForm.Execute(buf, &lf); err != nil {
|
||||||
|
log.Err(err).Msg("unable to generate template")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(401)
|
||||||
|
fmt.Fprint(w, buf.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := s.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to create a new session")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
cookie := session.GenerateCookie()
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
|
||||||
|
loginSuccess := templates.GetLoginSuccess()
|
||||||
|
if loginSuccess == nil {
|
||||||
|
log.Error().Msg("unable to load login success")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(w, loginSuccess.Tree.Root.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogin(w http.ResponseWriter, r *http.Request, s *services.SessionStore) {
|
||||||
|
loginForm := templates.GetLoginForm()
|
||||||
|
if loginForm == nil {
|
||||||
|
log.Error().Msg("unable to load login form")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.IsLogged(r) {
|
||||||
|
loginSuccess := templates.GetLoginSuccess()
|
||||||
|
if loginSuccess == nil {
|
||||||
|
log.Error().Msg("unable to load login success")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(w, loginSuccess.Tree.Root.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := bytes.NewBufferString("")
|
||||||
|
if err := loginForm.Execute(buf, &LoginForm{}); err != nil {
|
||||||
|
log.Err(err).Msg("unable to generate template")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprint(w, buf); err != nil {
|
||||||
|
log.Err(err).Msg("unable to write to response")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
handlers/login/templates/form.html.tpl
Normal file
66
handlers/login/templates/form.html.tpl
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
.main-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin: 10px;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Login</h1>
|
||||||
|
<form action="/login" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="main-container">
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="form-container">
|
||||||
|
<label>Username:</label>
|
||||||
|
<input type="text" name="username" value="{{.Username.Value}}" />
|
||||||
|
</div>
|
||||||
|
{{ if .Username.Err }}
|
||||||
|
<div class="error">{{.Username.Err}}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="form-container">
|
||||||
|
<label>Password:</label>
|
||||||
|
<input type="password" name="password" value="{{.Password.Value}}" />
|
||||||
|
</div>
|
||||||
|
{{ if .Password.Err }}
|
||||||
|
<div class="error">{{.Password.Err}}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="form-container">
|
||||||
|
<button id="submit" type="submit">Login</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{ if ne (errStr .Error) "" }}
|
||||||
|
<div class="error">{{.Error | errStr}}</div>
|
||||||
|
{{ end }}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
13
handlers/login/templates/success.html.tpl
Normal file
13
handlers/login/templates/success.html.tpl
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Login</h1>
|
||||||
|
<div>You're logged</div>
|
||||||
|
<h2>Available urls</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/upload">Upload a book</a></li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
42
handlers/login/templates/templates.go
Normal file
42
handlers/login/templates/templates.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"html/template"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed form.html.tpl
|
||||||
|
var form string
|
||||||
|
|
||||||
|
//go:embed success.html.tpl
|
||||||
|
var success string
|
||||||
|
|
||||||
|
var funcMap = template.FuncMap{
|
||||||
|
"errStr": func(err error) string {
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var GetLoginForm = sync.OnceValue[*template.Template](func() *template.Template {
|
||||||
|
tmpl, err := template.New("loginForm").Funcs(funcMap).Parse(form)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to parse login form")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tmpl
|
||||||
|
})
|
||||||
|
|
||||||
|
var GetLoginSuccess = sync.OnceValue[*template.Template](func() *template.Template {
|
||||||
|
tmpl, err := template.New("loginSuccess").Parse(success)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to parse login success")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tmpl
|
||||||
|
})
|
||||||
258
handlers/upload/handler.go
Normal file
258
handlers/upload/handler.go
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"librapi/handlers/upload/templates"
|
||||||
|
"librapi/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
const 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")
|
||||||
|
ErrUnauthorized = errors.New("unvalid authorization key")
|
||||||
|
)
|
||||||
|
|
||||||
|
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]
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
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 Handler(s *services.SessionStore) func(http.ResponseWriter, *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
getUploadFile(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
postUploadFile(w, r, s)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
editor := r.FormValue(bf.Editor.Name)
|
||||||
|
if editor == "" {
|
||||||
|
bf.Editor.Err = ErrInvalidEditor.Error()
|
||||||
|
}
|
||||||
|
bf.Editor.Value = editor
|
||||||
|
|
||||||
|
if a := r.FormValue(bf.Authors.Name); len(a) != 0 {
|
||||||
|
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); len(kw) != 0 {
|
||||||
|
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, s *services.SessionStore) {
|
||||||
|
uploadForm := templates.GetUploadForm()
|
||||||
|
if uploadForm == nil {
|
||||||
|
log.Error().Msg("unable to load upload form")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.IsLogged(r) {
|
||||||
|
buf := bytes.NewBufferString("")
|
||||||
|
if err := uploadForm.Execute(buf, &BookForm{Error: services.ErrUnauthorized.Error()}); err != nil {
|
||||||
|
log.Err(err).Msg("unable to generate template")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(401)
|
||||||
|
fmt.Fprint(w, buf.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := bytes.NewBufferString("")
|
||||||
|
bf := extractBookForm(r)
|
||||||
|
if err := uploadForm.Execute(buf, &bf); err != nil {
|
||||||
|
log.Err(err).Msg("unable to generate template")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bf.HasErrors() {
|
||||||
|
w.WriteHeader(400)
|
||||||
|
fmt.Fprint(w, buf.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := bf.File.Value.GetFilename()
|
||||||
|
log.Info().Str("filename", filename).Msg("file is uploading...")
|
||||||
|
|
||||||
|
dst, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
if err := uploadForm.Execute(buf, &BookForm{Error: "unexpected error occured while creating file"}); err != nil {
|
||||||
|
log.Err(err).Msg("unable to generate template")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(500)
|
||||||
|
fmt.Fprint(w, buf.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, bf.File.Value.file); err != nil {
|
||||||
|
if err := uploadForm.Execute(buf, &BookForm{Error: "unexpected error occured while uploading file"}); err != nil {
|
||||||
|
log.Err(err).Msg("unable to generate template")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(500)
|
||||||
|
fmt.Fprint(w, buf.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
if err := uploadForm.Execute(buf, &BookForm{Method: http.MethodPost}); err != nil {
|
||||||
|
log.Err(err).Msg("unable to generate template")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(w, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uploadForm := templates.GetUploadForm()
|
||||||
|
if uploadForm == nil {
|
||||||
|
log.Error().Msg("unable to load upload form")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := bytes.NewBufferString("")
|
||||||
|
if err := uploadForm.Execute(buf, &BookForm{}); err != nil {
|
||||||
|
log.Err(err).Msg("unable to generate template")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprint(w, buf); err != nil {
|
||||||
|
log.Err(err).Msg("unable to write to response")
|
||||||
|
http.Error(w, "unexpected error occurred", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
handlers/upload/templates/form.html.tpl
Normal file
106
handlers/upload/templates/form.html.tpl
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
.main-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin: 10px;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Upload a book</h1>
|
||||||
|
<form action="/upload" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="main-container">
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="form-container">
|
||||||
|
<label>Book name:</label>
|
||||||
|
<input type="text" name="name" value="{{.Name.Value}}" />
|
||||||
|
</div>
|
||||||
|
{{ if .Name.Err }}
|
||||||
|
<div class="error">{{.Name.Err}}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="form-container">
|
||||||
|
<label>Editor:</label>
|
||||||
|
<input type="text" name="editor" value="{{.Editor.Value}}" />
|
||||||
|
</div>
|
||||||
|
{{ if .Editor.Err }}
|
||||||
|
<div class="error">{{.Editor.Err}}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="form-container">
|
||||||
|
<label>Authors:</label>
|
||||||
|
<input type="text" name="authors" value="{{.Authors.Value | join}}" />
|
||||||
|
</div>
|
||||||
|
{{ if .Authors.Err }}
|
||||||
|
<div class="error">{{.Authors.Err}}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="form-container">
|
||||||
|
<label>Year:</label>
|
||||||
|
<input type="number" name="year" value="{{.Year.Value | year}}" />
|
||||||
|
</div>
|
||||||
|
{{ if .Year.Err }}
|
||||||
|
<div class="error">{{.Year.Err}}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="form-container">
|
||||||
|
<label>Keywords:</label>
|
||||||
|
<input type="text" name="keywords" value="{{.Keywords.Value | join}}" />
|
||||||
|
</div>
|
||||||
|
{{ if .Keywords.Err }}
|
||||||
|
<div class="error">{{.Keywords.Err}}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="form-container">
|
||||||
|
<input type="file" name="file" files="{{.File.Value.Header | filename}}" accept=".pdf" />
|
||||||
|
<button id="submit" type="submit">Upload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{ if .File.Err }}
|
||||||
|
<div class="error">{{.File.Err}}</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ if ne .Error "" }}
|
||||||
|
<div class="error">{{.Error}}</div>
|
||||||
|
{{ end }}
|
||||||
|
</body>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var submit = document.getElementById("submit");
|
||||||
|
submit.addEventListener("submit", function () {
|
||||||
|
submit.disabled = true;
|
||||||
|
});
|
||||||
|
{ { if .IsSuccess } }
|
||||||
|
alert("file uploaded successfully");
|
||||||
|
{ { end } }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</html>
|
||||||
46
handlers/upload/templates/templates.go
Normal file
46
handlers/upload/templates/templates.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"html/template"
|
||||||
|
"mime/multipart"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var funcMap = template.FuncMap{
|
||||||
|
"year": func(s int) string {
|
||||||
|
if s == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(s)
|
||||||
|
},
|
||||||
|
"join": func(s []string) string {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return ""
|
||||||
|
} else {
|
||||||
|
return strings.Join(s, ",")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filename": func(h *multipart.FileHeader) string {
|
||||||
|
if h != nil {
|
||||||
|
return h.Filename
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed form.html.tpl
|
||||||
|
var form string
|
||||||
|
|
||||||
|
var GetUploadForm = sync.OnceValue[*template.Template](func() *template.Template {
|
||||||
|
tmpl, err := template.New("uploadForm").Funcs(funcMap).Parse(form)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to parse upload form")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tmpl
|
||||||
|
})
|
||||||
53
main.go
Normal file
53
main.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"librapi/server"
|
||||||
|
"librapi/services"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"librapi/handlers/login"
|
||||||
|
"librapi/handlers/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultPort = 8585
|
||||||
|
|
||||||
|
var APIPort = sync.OnceValue[int](func() int {
|
||||||
|
port, err := strconv.Atoi(os.Getenv("API_PORT"))
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().Err(err).Msg("unable to load API_PORT")
|
||||||
|
return DefaultPort
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
})
|
||||||
|
|
||||||
|
func initLogger() {
|
||||||
|
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||||
|
log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
initLogger()
|
||||||
|
|
||||||
|
ctx, fnCancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt)
|
||||||
|
defer fnCancel()
|
||||||
|
|
||||||
|
sessionStore := services.NewSessionStore(ctx)
|
||||||
|
|
||||||
|
srv := server.NewServer(
|
||||||
|
ctx,
|
||||||
|
APIPort(),
|
||||||
|
server.NewHandler("/upload", upload.Handler(sessionStore)),
|
||||||
|
server.NewHandler("/login", login.Handler(sessionStore)),
|
||||||
|
)
|
||||||
|
srv.Serve()
|
||||||
|
|
||||||
|
<-srv.Done()
|
||||||
|
<-sessionStore.Done()
|
||||||
|
}
|
||||||
90
server/server.go
Normal file
90
server/server.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServerShutdownTimeout = 10 * time.Second
|
||||||
|
ServerReadTimeout = 5 * time.Second
|
||||||
|
DefaultPort = 8888
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
url string
|
||||||
|
fnHandle func(http.ResponseWriter, *http.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(url string, fnHandle func(http.ResponseWriter, *http.Request)) Handler {
|
||||||
|
return Handler{url: url, fnHandle: fnHandle}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
*http.Server
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerOption func()
|
||||||
|
|
||||||
|
func NewServer(ctx context.Context, port int, handlers ...Handler) Server {
|
||||||
|
if port == 0 {
|
||||||
|
log.Warn().Int("port", DefaultPort).Msg("no port detected, set to default")
|
||||||
|
port = DefaultPort
|
||||||
|
}
|
||||||
|
|
||||||
|
srvmux := http.NewServeMux()
|
||||||
|
for _, h := range handlers {
|
||||||
|
srvmux.HandleFunc(h.url, h.fnHandle)
|
||||||
|
}
|
||||||
|
srv := http.Server{
|
||||||
|
Addr: ":" + strconv.Itoa(port),
|
||||||
|
Handler: srvmux,
|
||||||
|
ReadTimeout: ServerReadTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
server := Server{
|
||||||
|
Server: &srv,
|
||||||
|
ctx: ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
if err := server.Stop(); err != nil {
|
||||||
|
log.Err(err).Msg("unable to stop the server correctly")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) Serve() {
|
||||||
|
log.Info().Str("addr", srv.Addr).Msg("http server listening")
|
||||||
|
|
||||||
|
if err := srv.ListenAndServe(); err != nil {
|
||||||
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Err(err).Msg("error occurred while serving server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info().Msg("server stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) Stop() error {
|
||||||
|
log.Info().Msg("stopping server...")
|
||||||
|
|
||||||
|
shutdownCtx, fnCancel := context.WithTimeout(srv.ctx, ServerShutdownTimeout)
|
||||||
|
defer fnCancel()
|
||||||
|
|
||||||
|
return srv.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) Done() <-chan struct{} {
|
||||||
|
return srv.ctx.Done()
|
||||||
|
}
|
||||||
162
services/sessions.go
Normal file
162
services/sessions.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultAPISessionExpirationDuration = 30 * time.Second
|
||||||
|
|
||||||
|
var APISessionExpirationDuration = sync.OnceValue[time.Duration](func() time.Duration {
|
||||||
|
expirationDuration, err := strconv.Atoi(os.Getenv("API_SESSION_EXPIRATION_DURATION"))
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().Err(err).Msg("unable to load API_SESSION_EXPIRATION_DURATION")
|
||||||
|
return defaultAPISessionExpirationDuration
|
||||||
|
}
|
||||||
|
return time.Duration(expirationDuration * int(time.Second))
|
||||||
|
})
|
||||||
|
|
||||||
|
var APISecure = sync.OnceValue[bool](func() bool {
|
||||||
|
return os.Getenv("API_SECURE") == "true"
|
||||||
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSessionIdCollision = errors.New("sessionId collision")
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateSessionID() (string, error) {
|
||||||
|
sessionID := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(sessionID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(sessionID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
l sync.RWMutex
|
||||||
|
sessionId string
|
||||||
|
expirationTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) GenerateCookie() *http.Cookie {
|
||||||
|
s.l.RLock()
|
||||||
|
defer s.l.RUnlock()
|
||||||
|
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: "session_id",
|
||||||
|
Value: s.sessionId,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: APISecure(),
|
||||||
|
Expires: s.expirationTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStore struct {
|
||||||
|
l sync.RWMutex
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
fnCancel context.CancelFunc
|
||||||
|
|
||||||
|
sessions map[string]*Session
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionStore(ctx context.Context) *SessionStore {
|
||||||
|
ctxChild, fnCancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
s := &SessionStore{
|
||||||
|
ctx: ctxChild,
|
||||||
|
fnCancel: fnCancel,
|
||||||
|
sessions: map[string]*Session{},
|
||||||
|
}
|
||||||
|
s.purgeWorker()
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) purge() {
|
||||||
|
s.l.Lock()
|
||||||
|
defer s.l.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
toDelete := []*Session{}
|
||||||
|
for _, session := range s.sessions {
|
||||||
|
if now.After(session.expirationTime) {
|
||||||
|
toDelete = append(toDelete, session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, session := range toDelete {
|
||||||
|
log.Debug().Str("sessionId", session.sessionId).Msg("purge expired session")
|
||||||
|
delete(s.sessions, session.sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) purgeWorker() {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.purge()
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
log.Info().Msg("purge worker stopped")
|
||||||
|
ticker.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Stop() {
|
||||||
|
s.fnCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) Done() <-chan struct{} {
|
||||||
|
return s.ctx.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) NewSession() (*Session, error) {
|
||||||
|
sessionId, err := generateSessionID()
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("unable to generate sessionId")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.l.Lock()
|
||||||
|
defer s.l.Unlock()
|
||||||
|
|
||||||
|
if _, ok := s.sessions[sessionId]; ok {
|
||||||
|
log.Error().Str("sessionId", sessionId).Msg("sessionId collision")
|
||||||
|
return nil, ErrSessionIdCollision
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Add(APISessionExpirationDuration())
|
||||||
|
session := Session{expirationTime: now, sessionId: sessionId}
|
||||||
|
s.sessions[sessionId] = &session
|
||||||
|
|
||||||
|
return &session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) IsLogged(r *http.Request) bool {
|
||||||
|
cookie, err := r.Cookie("session_id")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.l.RLock()
|
||||||
|
defer s.l.RUnlock()
|
||||||
|
|
||||||
|
_, ok := s.sessions[cookie.Value]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user