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,
}
}