390 lines
11 KiB
Go
390 lines
11 KiB
Go
/*
|
|
The webservice package is used to provide a webservice with sessions:
|
|
|
|
const VERSION = "v1"
|
|
|
|
var logger *slog.Logger
|
|
|
|
func sqlProvider(dbname string, version int) (sql []byte, found bool) {
|
|
var err error
|
|
sql, err = embeddedSQL.ReadFile(fmt.Sprintf("sql/%05d.sql", version))
|
|
if err != nil {
|
|
return
|
|
}
|
|
found = true
|
|
return
|
|
}
|
|
|
|
|
|
func init() {
|
|
opts := slog.HandlerOptions{}
|
|
logger = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
|
|
}
|
|
|
|
func main() {
|
|
service, err := webservice.New("/etc/some/webservice.yaml", VERSION, logger)
|
|
if err != nil {
|
|
logger.Error("application", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
service.SetDatabase(sqlProvider)
|
|
service.SetAuthenticationHandler(authenticate)
|
|
service.SetAuthorizationHandler(authorize)
|
|
service.Register("/foo", true, true, foo)
|
|
service.Register("/bar", true, false, bar)
|
|
err = service.Start()
|
|
if err != nil {
|
|
logger.Error("webserver", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
*/
|
|
package webservice
|
|
|
|
import (
|
|
// Internal
|
|
"git.gibonuddevalla.se/go/webservice/config"
|
|
"git.gibonuddevalla.se/go/webservice/database"
|
|
"git.gibonuddevalla.se/go/webservice/session"
|
|
"git.gibonuddevalla.se/go/webservice/ws_conn_manager"
|
|
|
|
// Standard
|
|
"bufio"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const VERSION = "v0.2.17"
|
|
|
|
type HttpHandler func(http.ResponseWriter, *http.Request)
|
|
|
|
type ErrorHandler func(err error, code string, w http.ResponseWriter)
|
|
type ServiceError struct {
|
|
OK bool
|
|
Code string
|
|
Error string
|
|
}
|
|
|
|
type Service struct {
|
|
logger *slog.Logger
|
|
sessions map[string]*session.T
|
|
Config config.Config
|
|
Db *database.T
|
|
Version string
|
|
WsConnectionManager ws_conn_manager.ConnectionManager
|
|
|
|
errorHandler ErrorHandler
|
|
authenticationHandler AuthenticationHandler
|
|
authorizationHandler AuthorizationHandler
|
|
|
|
staticSubFs fs.FS
|
|
useStaticDirectory bool
|
|
staticDirectory string
|
|
staticEmbeddedFileserver http.Handler
|
|
staticLocalFileserver http.Handler
|
|
}
|
|
|
|
type ServiceHandler func(http.ResponseWriter, *http.Request, *session.T)
|
|
|
|
func New(configFilename, version string, logger *slog.Logger) (service *Service, err error) { // {{{
|
|
service = new(Service)
|
|
|
|
service.Config, err = config.New(configFilename)
|
|
if err != nil {
|
|
return
|
|
}
|
|
logger.Debug("config", "config", service.Config)
|
|
|
|
service.Version = version
|
|
service.logger = logger
|
|
service.sessions = make(map[string]*session.T, 128)
|
|
service.errorHandler = service.defaultErrorHandler
|
|
service.authenticationHandler = service.defaultAuthenticationHandler
|
|
service.authorizationHandler = service.defaultAuthorizationHandler
|
|
service.WsConnectionManager = ws_conn_manager.NewConnectionManager(service.logger, service.Config.Websocket.Domains)
|
|
|
|
service.Register("/_session/new", false, false, service.sessionNew)
|
|
service.Register("/_session/authenticate", true, false, service.sessionAuthenticate)
|
|
service.Register("/_session/retrieve", true, false, service.sessionRetrieve)
|
|
|
|
http.HandleFunc("/_ws", service.websocketHandler)
|
|
http.HandleFunc("/_ws/css_update", service.cssUpdateHandler)
|
|
|
|
http.HandleFunc("/_js/", service.staticJSHandler)
|
|
|
|
return
|
|
} // }}}
|
|
|
|
func (service *Service) defaultAuthenticationHandler(req AuthenticationRequest, sess *session.T, alreadyAuthenticated bool) (resp AuthenticationResponse, err error) { // {{{
|
|
resp.Authenticated = alreadyAuthenticated
|
|
service.logger.Info("webservice", "op", "authentication", "username", req.Username, "authenticated", resp.Authenticated)
|
|
return
|
|
} // }}}
|
|
func (service *Service) defaultAuthorizationHandler(sess *session.T, r *http.Request) (resp bool, err error) { // {{{
|
|
resp = true
|
|
service.logger.Debug("webservice", "op", "authorization", "session", sess.UUID, "request", r.URL.String(), "authorized", resp)
|
|
return
|
|
} // }}}
|
|
func (service *Service) defaultErrorHandler(err error, code string, w http.ResponseWriter) { // {{{
|
|
service.logger.Error("webservice", "error", err)
|
|
errMsg := ServiceError{}
|
|
errMsg.OK = false
|
|
errMsg.Code = code
|
|
errMsg.Error = err.Error()
|
|
errJSON, _ := json.Marshal(errMsg)
|
|
w.Write(errJSON)
|
|
} // }}}
|
|
|
|
func (service *Service) SetLogger(logger *slog.Logger) { // {{{
|
|
service.logger = logger
|
|
} // }}}
|
|
func (service *Service) SetErrorHandler(h ErrorHandler) { // {{{
|
|
service.errorHandler = h
|
|
} // }}}
|
|
func (service *Service) SetAuthenticationHandler(h AuthenticationHandler) { // {{{
|
|
service.authenticationHandler = h
|
|
} // }}}
|
|
func (service *Service) SetAuthorizationHandler(h AuthorizationHandler) { // {{{
|
|
service.authorizationHandler = h
|
|
} // }}}
|
|
func (service *Service) SetStaticFS(staticFS embed.FS, directory string) (err error) { // {{{
|
|
service.staticSubFs, err = fs.Sub(staticFS, directory)
|
|
if err != nil {
|
|
return
|
|
}
|
|
service.staticEmbeddedFileserver = http.FileServer(http.FS(service.staticSubFs))
|
|
return
|
|
} // }}}
|
|
func (service *Service) SetStaticDirectory(directory string, useDirectory bool) { // {{{
|
|
service.useStaticDirectory = useDirectory
|
|
service.staticDirectory = directory
|
|
service.staticLocalFileserver = http.FileServer(http.Dir(directory))
|
|
} // }}}
|
|
|
|
func (service *Service) SetDatabase(sqlProv database.SqlProvider) { // {{{
|
|
service.Db = database.New(service.Config.Database)
|
|
service.Db.SetLogger(service.logger)
|
|
service.Db.SetSQLProvider(sqlProv)
|
|
return
|
|
} // }}}
|
|
func (service *Service) Register(path string, requireSession, requireAuthentication bool, handler ServiceHandler) { // {{{
|
|
http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
|
var sess *session.T
|
|
var found bool
|
|
var authorized bool
|
|
var err error
|
|
|
|
if requireAuthentication && !requireSession {
|
|
requireSession = true
|
|
}
|
|
|
|
if requireSession {
|
|
headerSessionUUID, err := sessionUUID(r)
|
|
if err != nil {
|
|
service.errorHandler(fmt.Errorf("Header X-Session-ID missing"), "001-0000", w)
|
|
return
|
|
}
|
|
|
|
sess, found = service.RetrieveSession(headerSessionUUID)
|
|
if !found {
|
|
service.errorHandler(fmt.Errorf("Session '%s' not found", headerSessionUUID), "001-0001", w)
|
|
return
|
|
}
|
|
}
|
|
|
|
if requireAuthentication {
|
|
if !sess.Authenticated {
|
|
service.errorHandler(fmt.Errorf("Session '%s' not authenticated", sess.UUID), "001-0002", w)
|
|
return
|
|
}
|
|
|
|
authorized, err = service.authorizationHandler(sess, r)
|
|
if err != nil {
|
|
service.errorHandler(err, "001-F001", w)
|
|
return
|
|
}
|
|
if !authorized {
|
|
service.errorHandler(fmt.Errorf("Session '%s' not authorized for %s", sess.UUID, r.URL.String()), "001-0003", w)
|
|
return
|
|
}
|
|
}
|
|
|
|
service.logger.Info("webserver", "op", "request", "path", r.URL.String())
|
|
handler(w, r, sess)
|
|
})
|
|
} // }}}
|
|
func (service *Service) InitDatabaseConnection() (err error) { // {{{
|
|
err = service.Db.Upgrade()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = service.Db.Connect()
|
|
if err != nil {
|
|
return
|
|
}
|
|
return
|
|
} // }}}
|
|
func (service *Service) CreateUser(username, password, name string) (userID int64, err error) { // {{{
|
|
if service.Db != nil {
|
|
err = service.InitDatabaseConnection()
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
userID, err = service.Db.CreateUser(username, password, name)
|
|
return
|
|
} // }}}
|
|
func (service *Service) CreateUserPrompt() { // {{{
|
|
var err error
|
|
var username, name, password string
|
|
reader := bufio.NewReader(os.Stdin)
|
|
|
|
fmt.Printf("Username: ")
|
|
username, _ = reader.ReadString('\n')
|
|
username = strings.TrimSpace(username)
|
|
|
|
fmt.Printf("Name: ")
|
|
name, _ = reader.ReadString('\n')
|
|
name = strings.TrimSpace(name)
|
|
|
|
fmt.Printf("Password: ")
|
|
password, _ = reader.ReadString('\n')
|
|
password = strings.TrimSpace(password)
|
|
|
|
_, err = service.CreateUser(username, password, name)
|
|
if err != nil {
|
|
service.logger.Error("application", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
} // }}}
|
|
func (service *Service) Start() (err error) { // {{{
|
|
if service.Db != nil {
|
|
err = service.InitDatabaseConnection()
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
go service.WsConnectionManager.BroadcastLoop()
|
|
|
|
listen := fmt.Sprintf("%s:%d", service.Config.Network.Address, service.Config.Network.Port)
|
|
service.logger.Info("webserver", "listen", listen)
|
|
err = http.ListenAndServe(listen, nil)
|
|
return
|
|
} // }}}
|
|
|
|
func (service *Service) StaticHandler(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
|
var err error
|
|
|
|
data := struct{ VERSION string }{service.Version}
|
|
|
|
// URLs with pattern /(css|images)/v1.0.0/foobar are stripped of the version.
|
|
// To get rid of problems with cached content in browser on a new version release,
|
|
// while also not disabling cache altogether.
|
|
if r.URL.Path == "/favicon.ico" {
|
|
service.staticEmbeddedFileserver.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
rxp := regexp.MustCompile("^/(css|images|js|fonts)/v[0-9]+/(.*)$")
|
|
if comp := rxp.FindStringSubmatch(r.URL.Path); comp != nil {
|
|
w.Header().Add("Pragma", "public")
|
|
w.Header().Add("Cache-Control", "max-age=604800")
|
|
|
|
r.URL.Path = fmt.Sprintf("/%s/%s", comp[1], comp[2])
|
|
p := fmt.Sprintf(service.staticDirectory+"/%s/%s", comp[1], comp[2])
|
|
_, err = os.Stat(p)
|
|
if err == nil {
|
|
service.staticLocalFileserver.ServeHTTP(w, r)
|
|
} else {
|
|
service.staticEmbeddedFileserver.ServeHTTP(w, r)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Everything else is run through the template system.
|
|
// For now to get VERSION into files to fix caching.
|
|
//log.Printf("template: %s", r.URL.Path)
|
|
tmpl, err := service.newTemplate(r.URL.Path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
w.WriteHeader(404)
|
|
}
|
|
w.Write([]byte(err.Error()))
|
|
return
|
|
}
|
|
|
|
if err = tmpl.Execute(w, data); err != nil {
|
|
w.Write([]byte(err.Error()))
|
|
}
|
|
} // }}}
|
|
func (service *Service) websocketHandler(w http.ResponseWriter, r *http.Request) { // {{{
|
|
service.logger.Debug("websocket", "op", "connect")
|
|
var err error
|
|
|
|
_, err = service.WsConnectionManager.NewConnection(w, r)
|
|
if err != nil {
|
|
service.logger.Error("websocket", "error", err)
|
|
return
|
|
}
|
|
} // }}}
|
|
func (service *Service) cssUpdateHandler(w http.ResponseWriter, r *http.Request) { // {{{
|
|
service.logger.Debug("websocket", "css", "updated")
|
|
service.WsConnectionManager.Broadcast(struct {
|
|
OK bool
|
|
ID string
|
|
Op string
|
|
}{
|
|
OK: true,
|
|
Op: "css_reload",
|
|
})
|
|
} // }}}
|
|
|
|
func (service *Service) newTemplate(requestPath string) (tmpl *template.Template, err error) { // {{{
|
|
// Append index.html if needed for further reading of the file
|
|
p := requestPath
|
|
if p[len(p)-1] == '/' {
|
|
p += "index.html"
|
|
}
|
|
|
|
if p[0:1] == "/" {
|
|
p = p[1:]
|
|
}
|
|
|
|
// Try local disk files for faster testing
|
|
if service.useStaticDirectory {
|
|
_, err = os.Stat(service.staticDirectory + "/" + p)
|
|
if err == nil {
|
|
tmpl, err = template.ParseFiles(service.staticDirectory + "/" + p)
|
|
return
|
|
}
|
|
}
|
|
|
|
tmpl, err = template.ParseFS(service.staticSubFs, p)
|
|
return
|
|
} // }}}
|
|
func sessionUUID(r *http.Request) (string, error) { // {{{
|
|
headers := r.Header["X-Session-Id"]
|
|
if len(headers) > 0 {
|
|
return headers[0], nil
|
|
} else {
|
|
cookie, err := r.Cookie("X-Session-ID")
|
|
if err == nil && cookie.Value != "" {
|
|
return cookie.Value, nil
|
|
}
|
|
}
|
|
return "", errors.New("Invalid session")
|
|
} // }}}
|