package serve import ( "context" "database/sql" "log/slog" "net/http" "os" "strings" _ "modernc.org/sqlite" "git.awl.red/~neallred/recipes/htp" "git.awl.red/~neallred/recipes/logging" "git.awl.red/~neallred/recipes/model" "git.awl.red/~neallred/recipes/recipe" "git.awl.red/~neallred/recipes/user" ) var userRole = model.RoleUser var adminRole = model.RoleAdmin type middlewares struct { log *slog.Logger // user must have role or request will fail role *model.Role // add user auth if it exists, but do not fail if it does not exist // useful, for example, if certain recipes are public but // some require being logged in or being the same user to view addUserAuth bool db *sql.DB maxRead *int64 } func (m middlewares) Srv(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, rOrig *http.Request) { r := rOrig if m.maxRead != nil { r.Body = http.MaxBytesReader(w, r.Body, *m.maxRead) } if m.log != nil { r = logging.ToReq(r, m.log) } if m.addUserAuth { if m.db != nil { userAuth, err := user.AuthFromHttp(w, r, m.db) if err == nil { r = user.AuthToReq(r, userAuth) } else { if err != sql.ErrNoRows && err != user.ErrFailGetAuthCookie && err != user.ErrNoAuthCookie { m.log.Warn("Unable to add auth for user", "err", err, "err_id", "disspread") } } } else { m.log.Warn("Unable to add auth for user, no db") } } else if m.role != nil { if m.db == nil { htp.W500(w, m.log, nil, "glaucophanize", "db not configured when it should be") return } userAuth, err := user.AuthFromHttp(w, r, m.db) if err != nil { w.WriteHeader(http.StatusUnauthorized) m.log.Error("could not authorize user", "err", err) return } can, ok := userAuth.Priveleges[*m.role] if !can || !ok { w.WriteHeader(http.StatusForbidden) var rolesStr strings.Builder for _, role := range userAuth.Roles { rolesStr.WriteString(string(role)) rolesStr.WriteString(",") } m.log.Warn("Forbidden user attempt", "user_id", userAuth.UserId, "required", m.role, "has", rolesStr.String(), "method", r.Method, "path", r.URL.Path, ) return } r = user.AuthToReq(r, userAuth) } next.ServeHTTP(w, r) }) } func τ___GET(path string, f http.Handler) { http.Handle("GET "+path, f) } func τ__POST(path string, f http.Handler) { http.Handle("POST "+path, f) } func τ_PATCH(path string, f http.Handler) { http.Handle("PATCH "+path, f) } func τDELETE(path string, f http.Handler) { http.Handle("DELETE "+path, f) } const apiPrefix = "/api/v1" // e.g. 10 MiB var maxReadBytes int64 = 1024 * 1024 * 10 type Config struct { Port string DbConn string } type Server struct { server *http.Server log *slog.Logger config Config db *sql.DB } // blocks (calls http.ListenAndServe) // For use in tests, do Start in a goroutine func (s *Server) Start() { s.log.Info("serving", "port", s.config.Port) s.log.Info("ended server", "err", http.ListenAndServe(":"+s.config.Port, nil)) } func (s *Server) Close() error { err := s.server.Shutdown(context.Background()) if err != nil { return err } err = s.db.Close() if err != nil { return err } return nil } func h(f func(w http.ResponseWriter, r *http.Request)) http.Handler { return http.HandlerFunc(f) } func healthz(w http.ResponseWriter, r *http.Request) { w.Write([]byte("<html><head></head><body><h1>OK</h1></body></html>")) } func New(config Config) *Server { log := logging.New() // db, err := sql.Open("sqlite3", config.DbConn) db, err := sql.Open("sqlite", config.DbConn) if err != nil { log.Error("could not open db", "err", err) os.Exit(1) } userApi := user.NewRestApi(db) htmlRecipeApi := recipe.NewHtmlApi(db) htmlUserApi := user.NewHtmlApi(db) wLog := middlewares{log: log, maxRead: &maxReadBytes} ifUser := middlewares{log: log, role: &userRole, db: db, maxRead: &maxReadBytes} ifAdmin := middlewares{log: log, role: &adminRole, db: db, maxRead: &maxReadBytes} optUser := middlewares{log: log, addUserAuth: true, db: db, maxRead: &maxReadBytes} τ___GET("/healthz", h(healthz)) τ___GET("/user/edit", ifUser.Srv(h(htmlUserApi.GetUserEdit))) τ_PATCH("/user", ifUser.Srv(h(htmlUserApi.UpdateUser))) τ__POST(apiPrefix+"/user/role", ifAdmin.Srv(h(userApi.AddRole))) τ__POST("/later_notes", ifUser.Srv(h(htmlUserApi.SetLaterNotes))) τ___GET(apiPrefix+"/users/roles", ifAdmin.Srv(h(userApi.GetUserRoles))) τDELETE("/recipes/{id}", ifUser.Srv(h(htmlRecipeApi.DeleteRecipe))) τ__POST("/recipes/{id}", ifUser.Srv(h(htmlRecipeApi.EditRecipe))) recHandler := optUser.Srv(h(htmlRecipeApi.GetRecipes)) τ___GET("/{$}", recHandler) τ___GET("/recipes", recHandler) τ___GET("/recipes_search", optUser.Srv(h(htmlRecipeApi.GetRecipesSearch))) τ__POST("/recipes", ifUser.Srv(h(htmlRecipeApi.AddRecipe))) τ___GET("/recipe_new", ifUser.Srv(h(htmlRecipeApi.NewRecipe))) τ___GET("/recipes/{id}", optUser.Srv(h(htmlRecipeApi.GetRecipeDetails))) τ___GET("/recipes/{id}/edit", optUser.Srv(h(htmlRecipeApi.GetRecipeEdit))) τ__POST("/recipes/{id}/later", ifUser.Srv(h(htmlUserApi.AddLaterRecipe))) τDELETE("/recipes/{id}/later", ifUser.Srv(h(htmlUserApi.DeleteLaterRecipe))) τ___GET("/recipes_for_later", ifUser.Srv(h(htmlUserApi.GetLaterNotes))) τ___GET("/recipes_for_later/edit", ifUser.Srv(h(htmlUserApi.GetLaterNotesEdit))) τ___GET("/login", optUser.Srv(h(htmlUserApi.GetLogin))) τ__POST("/login", optUser.Srv(h(htmlUserApi.PostLogin))) τ___GET("/signup", optUser.Srv(h(htmlUserApi.GetSignup))) τ__POST("/signup", optUser.Srv(h(htmlUserApi.PostSignup))) τ___GET("/logout", optUser.Srv(h(htmlUserApi.Logout))) τ__POST("/logout", optUser.Srv(h(htmlUserApi.Logout))) initStaticRoutes(wLog.Srv(h(staticHandler))) server := &http.Server{Addr: ":" + config.Port} return &Server{ server: server, log: log, db: db, config: config, } }