webservice/pkg.go

387 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.14"
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 {
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")
} // }}}