package webserver import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "io" "mime" "net/http" "os" "strings" "golang.org/x/crypto/bcrypt" "git.stormux.org/storm/skald/hall" "git.stormux.org/storm/skald/stats" "git.stormux.org/storm/skald/token" ) // checkAdmin checks whether the client authentifies as an administrator func checkAdmin(w http.ResponseWriter, r *http.Request) bool { username, password, ok := r.BasicAuth() if ok { ok, _ = adminMatch(username, password) } if !ok { failAuthentication(w, "/skald-api/", "Authentication required. Provide valid administrator credentials.") return false } return true } // checkPasswordAdmin checks whether the client authentifies as either an // administrator or the given user. It is used to check whether the // client has the right to change user's password. func checkPasswordAdmin(w http.ResponseWriter, r *http.Request, hallname, user string, wildcard bool) bool { username, password, ok := r.BasicAuth() if ok { ok, err := adminMatch(username, password) if err != nil { internalError(w, "Admin match: %v", err) return false } if ok { return true } } if ok && !wildcard && username == user { desc, err := hall.GetDescription(hallname) if err != nil { internalError(w, "Get description for hall %v: %v", hallname, err, ) return false } if desc.Users != nil { u, ok := desc.Users[user] if ok { ok, err := u.Password.Match(password) if err != nil { internalError(w, "Password match: %v", err, ) return false } if ok { return true } } } } failAuthentication(w, "/skald-api/", "Authentication required. Provide valid administrator credentials.") return false } func sendJSON(w http.ResponseWriter, r *http.Request, v any) { w.Header().Set("content-type", "application/json") if r.Method == "HEAD" { return } e := json.NewEncoder(w) e.Encode(v) } const maxAPIMessageSize = 1024 * 1024 func getText(w http.ResponseWriter, r *http.Request) ([]byte, bool) { ctype, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) if err != nil || !strings.EqualFold(ctype, "text/plain") { w.Header().Set("Accept", "text/plain") http.Error(w, "unsupported content type", http.StatusUnsupportedMediaType) return nil, true } body, err := io.ReadAll( http.MaxBytesReader(w, r.Body, maxAPIMessageSize), ) if err != nil { httpError(w, err) return nil, true } return body, false } func getJSON(w http.ResponseWriter, r *http.Request, v any) bool { ctype, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) if err != nil || !strings.EqualFold(ctype, "application/json") { w.Header().Set("Accept", "application/json") http.Error(w, "unsupported content type", http.StatusUnsupportedMediaType) return true } d := json.NewDecoder( http.MaxBytesReader(w, r.Body, maxAPIMessageSize), ) err = d.Decode(v) if err != nil { httpError(w, err) return true } return false } func apiCORS(w http.ResponseWriter, r *http.Request, methods string) bool { CheckOrigin(w, r, true) if r.Method == "OPTIONS" { w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, "+methods) w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type", ) return true } return false } func apiHandler(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/skald-api/") { http.NotFound(w, r) return } first, kind, rest := splitPath(r.URL.Path[len("/skald-api"):]) if first != "/v0" { http.NotFound(w, r) return } switch kind { case ".stats": if rest != "" { http.NotFound(w, r) return } if apiCORS(w, r, "HEAD, GET") { return } if !checkAdmin(w, r) { return } if r.Method != "HEAD" && r.Method != "GET" { methodNotAllowed(w, "HEAD, GET") return } w.Header().Set("cache-control", "no-cache") sendJSON(w, r, stats.GetHalls()) case ".halls": apiHallHandler(w, r, rest) default: http.NotFound(w, r) } } func apiHallHandler(w http.ResponseWriter, r *http.Request, pth string) { first, kind, rest := splitPath(pth) g := "" if first != "" { g = strings.TrimSuffix(first[1:], "/") } if g == "" && kind == "" { if apiCORS(w, r, "HEAD, GET") { return } if !checkAdmin(w, r) { return } if r.Method != "HEAD" && r.Method != "GET" { methodNotAllowed(w, "HEAD, GET") return } halls, err := hall.GetDescriptionNames() if err != nil { httpError(w, err) return } sendJSON(w, r, halls) return } if kind == ".users" { usersHandler(w, r, g, rest) return } else if kind == ".empty-user" { specialUserHandler(w, r, g, rest, false) return } else if kind == ".wildcard-user" { specialUserHandler(w, r, g, rest, true) return } else if kind == ".keys" && rest == "" { keysHandler(w, r, g) return } else if kind == ".tokens" { tokensHandler(w, r, g, rest) return } else if kind != "" { if !checkAdmin(w, r) { return } notFound(w) return } if apiCORS(w, r, "HEAD, GET, PUT, DELETE") { return } if !checkAdmin(w, r) { return } if r.Method == "HEAD" || r.Method == "GET" { desc, etag, err := hall.GetSanitisedDescription(g) if err != nil { httpError(w, err) return } w.Header().Set("etag", etag) done := checkPreconditions(w, r, etag) if done { return } sendJSON(w, r, desc) return } else if r.Method == "PUT" { etag, err := hall.GetDescriptionTag(g) if errors.Is(err, os.ErrNotExist) { err = nil etag = "" } else if err != nil { httpError(w, err) return } done := checkPreconditions(w, r, etag) if done { return } var newdesc hall.Description done = getJSON(w, r, &newdesc) if done { return } err = hall.UpdateDescription(g, etag, &newdesc) if err != nil { httpError(w, err) return } if etag == "" { w.WriteHeader(http.StatusCreated) } else { w.WriteHeader(http.StatusNoContent) } return } else if r.Method == "DELETE" { etag, err := hall.GetDescriptionTag(g) if err != nil { httpError(w, err) return } done := checkPreconditions(w, r, etag) if done { return } err = hall.DeleteDescription(g, etag) if err != nil { httpError(w, err) return } w.WriteHeader(http.StatusNoContent) return } methodNotAllowed(w, "HEAD, GET, PUT, DELETE") return } func usersHandler(w http.ResponseWriter, r *http.Request, g, pth string) { if pth == "" { http.NotFound(w, r) return } if pth == "/" { if apiCORS(w, r, "HEAD, GET") { return } if !checkAdmin(w, r) { return } if r.Method != "HEAD" && r.Method != "GET" { methodNotAllowed(w, "HEAD, GET") return } users, etag, err := hall.GetUsers(g) if err != nil { httpError(w, err) return } w.Header().Set("etag", etag) done := checkPreconditions(w, r, etag) if done { return } sendJSON(w, r, users) return } first2, kind2, rest2 := splitPath(pth) if first2 != "" && kind2 == "" { userHandler(w, r, g, first2[1:], false) return } else if first2 != "" && kind2 == ".password" && rest2 == "" { passwordHandler(w, r, g, first2[1:], false) return } if !checkAdmin(w, r) { return } notFound(w) return } func specialUserHandler(w http.ResponseWriter, r *http.Request, g, pth string, wildcard bool) { if pth == "" { userHandler(w, r, g, "", wildcard) return } else if pth == "/.password" { passwordHandler(w, r, g, "", wildcard) return } if !checkAdmin(w, r) { return } notFound(w) return } func userHandler(w http.ResponseWriter, r *http.Request, g, user string, wildcard bool) { if apiCORS(w, r, "HEAD, GET, PUT, DELETE") { return } if !checkAdmin(w, r) { return } if r.Method == "HEAD" || r.Method == "GET" { user, etag, err := hall.GetSanitisedUser(g, user, wildcard) if err != nil { httpError(w, err) return } w.Header().Set("etag", etag) done := checkPreconditions(w, r, etag) if done { return } sendJSON(w, r, user) return } else if r.Method == "PUT" { etag, err := hall.GetUserTag(g, user, wildcard) if errors.Is(err, os.ErrNotExist) { etag = "" err = nil } else if err != nil { httpError(w, err) return } done := checkPreconditions(w, r, etag) if done { return } var newdesc hall.UserDescription done = getJSON(w, r, &newdesc) if done { return } err = hall.UpdateUser(g, user, wildcard, etag, &newdesc) if err != nil { httpError(w, err) return } if etag == "" { w.WriteHeader(http.StatusCreated) } else { w.WriteHeader(http.StatusNoContent) } return } else if r.Method == "DELETE" { etag, err := hall.GetUserTag(g, user, wildcard) if err != nil { httpError(w, err) return } done := checkPreconditions(w, r, etag) if done { return } err = hall.DeleteUser(g, user, wildcard, etag) if err != nil { httpError(w, err) return } w.WriteHeader(http.StatusNoContent) return } methodNotAllowed(w, "HEAD, GET, PUT, DELETE") return } func passwordHandler(w http.ResponseWriter, r *http.Request, g, user string, wildcard bool) { if apiCORS(w, r, "PUT, POST, DELETE") { return } if !checkPasswordAdmin(w, r, g, user, wildcard) { return } if r.Method == "PUT" { var pw hall.Password done := getJSON(w, r, &pw) if done { return } err := hall.SetUserPassword(g, user, wildcard, pw) if err != nil { httpError(w, err) return } w.WriteHeader(http.StatusNoContent) return } else if r.Method == "POST" { body, done := getText(w, r) if done { return } key, err := bcrypt.GenerateFromPassword(body, 8) if err != nil { httpError(w, err) return } k := string(key) pw := hall.Password{ Type: "bcrypt", Key: &k, } err = hall.SetUserPassword(g, user, wildcard, pw) if err != nil { httpError(w, err) return } w.WriteHeader(http.StatusNoContent) return } else if r.Method == "DELETE" { err := hall.SetUserPassword(g, user, wildcard, hall.Password{}) if err != nil { httpError(w, err) return } w.WriteHeader(http.StatusNoContent) return } methodNotAllowed(w, "PUT, POST, DELETE") return } type jwkset = struct { Keys []map[string]any `json:"keys"` } func keysHandler(w http.ResponseWriter, r *http.Request, g string) { if apiCORS(w, r, "PUT, DELETE") { return } if !checkAdmin(w, r) { return } if r.Method == "PUT" { // cannot use getJSON due to the weird content-type ctype, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) if err != nil || !strings.EqualFold(ctype, "application/jwk-set+json") { w.Header().Set("Accept", "application/jwk-set+json") http.Error(w, "unsupported content type", http.StatusUnsupportedMediaType) return } d := json.NewDecoder( http.MaxBytesReader(w, r.Body, maxAPIMessageSize), ) var keys jwkset err = d.Decode(&keys) if err != nil { httpError(w, err) return } err = hall.SetKeys(g, keys.Keys) if err != nil { httpError(w, err) return } w.WriteHeader(http.StatusNoContent) return } else if r.Method == "DELETE" { err := hall.SetKeys(g, nil) if err != nil { httpError(w, err) return } w.WriteHeader(http.StatusNoContent) return } methodNotAllowed(w, "PUT, DELETE") return } func tokensHandler(w http.ResponseWriter, r *http.Request, g, pth string) { if pth == "" { http.NotFound(w, r) return } if apiCORS(w, r, "HEAD, GET, POST, PUT, DELETE") { return } if !checkAdmin(w, r) { return } if g != "" { // check that the hall exists _, err := hall.GetDescription(g) if err != nil { httpError(w, err) return } } if pth == "/" { if r.Method == "HEAD" || r.Method == "GET" { tokens, etag, err := token.List(g) if err != nil { httpError(w, err) return } w.Header().Set("content-type", "application/json") if etag != "" { w.Header().Set("etag", etag) } toknames := make([]string, len(tokens)) for i, t := range tokens { toknames[i] = t.Token } sendJSON(w, r, toknames) return } else if r.Method == "POST" { var newtoken token.Stateful done := getJSON(w, r, &newtoken) if done { return } if newtoken.Token != "" || newtoken.Hall != "" { http.Error(w, "overspecified token", http.StatusBadRequest) return } buf := make([]byte, 8) rand.Read(buf) newtoken.Token = base64.RawURLEncoding.EncodeToString(buf) newtoken.Hall = g t, err := token.Update(&newtoken, "") if err != nil { httpError(w, err) return } w.Header().Set("location", t.Token) w.WriteHeader(http.StatusCreated) return } methodNotAllowed(w, "HEAD, GET, POST") return } if pth[0] != '/' { http.NotFound(w, r) return } t := pth[1:] if r.Method == "HEAD" || r.Method == "GET" { old, etag, err := token.Get(t) if err != nil { httpError(w, err) return } if old.Hall != g { http.NotFound(w, r) return } tok := old.Clone() tok.Token = "" tok.Hall = "" w.Header().Set("etag", etag) done := checkPreconditions(w, r, etag) if done { return } sendJSON(w, r, tok) return } else if r.Method == "PUT" { old, etag, err := token.Get(t) if errors.Is(err, os.ErrNotExist) { etag = "" err = nil } else if err != nil { httpError(w, err) return } if old.Hall != g { http.Error(w, "token exists in different hall", http.StatusConflict) return } done := checkPreconditions(w, r, etag) if done { return } var newtoken token.Stateful done = getJSON(w, r, &newtoken) if done { return } if newtoken.Hall != "" || newtoken.Token != "" { http.Error(w, "overspecified token", http.StatusBadRequest) return } newtoken.Hall = g newtoken.Token = t _, err = token.Update(&newtoken, etag) if err != nil { httpError(w, err) return } if etag == "" { w.WriteHeader(http.StatusCreated) } else { w.WriteHeader(http.StatusNoContent) } return } else if r.Method == "DELETE" { old, etag, err := token.Get(t) if err != nil { httpError(w, err) return } if old.Hall != g { http.NotFound(w, r) return } done := checkPreconditions(w, r, etag) if done { return } err = token.Delete(t, etag) if err != nil { httpError(w, err) return } w.WriteHeader(http.StatusNoContent) return } methodNotAllowed(w, "HEAD, GET, PUT, DELETE") return }