/* 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") } // }}}