github.com/safing/portbase@v0.19.5/api/endpoints.go (about) 1 package api 2 3 import ( 4 "bytes" 5 "errors" 6 "fmt" 7 "io" 8 "net/http" 9 "sort" 10 "strconv" 11 "strings" 12 "sync" 13 14 "github.com/gorilla/mux" 15 16 "github.com/safing/portbase/database/record" 17 "github.com/safing/portbase/formats/dsd" 18 "github.com/safing/portbase/log" 19 "github.com/safing/portbase/modules" 20 ) 21 22 // Endpoint describes an API Endpoint. 23 // Path and at least one permission are required. 24 // As is exactly one function. 25 type Endpoint struct { //nolint:maligned 26 // Name is the human reabable name of the endpoint. 27 Name string 28 // Description is the human readable description and documentation of the endpoint. 29 Description string 30 // Parameters is the parameter documentation. 31 Parameters []Parameter `json:",omitempty"` 32 33 // Path describes the URL path of the endpoint. 34 Path string 35 36 // MimeType defines the content type of the returned data. 37 MimeType string 38 39 // Read defines the required read permission. 40 Read Permission `json:",omitempty"` 41 42 // ReadMethod sets the required read method for the endpoint. 43 // Available methods are: 44 // GET: Returns data only, no action is taken, nothing is changed. 45 // If omitted, defaults to GET. 46 // 47 // This field is currently being introduced and will only warn and not deny 48 // access if the write method does not match. 49 ReadMethod string `json:",omitempty"` 50 51 // Write defines the required write permission. 52 Write Permission `json:",omitempty"` 53 54 // WriteMethod sets the required write method for the endpoint. 55 // Available methods are: 56 // POST: Create a new resource; Change a status; Execute a function 57 // PUT: Update an existing resource 58 // DELETE: Remove an existing resource 59 // If omitted, defaults to POST. 60 // 61 // This field is currently being introduced and will only warn and not deny 62 // access if the write method does not match. 63 WriteMethod string `json:",omitempty"` 64 65 // BelongsTo defines which module this endpoint belongs to. 66 // The endpoint will not be accessible if the module is not online. 67 BelongsTo *modules.Module `json:"-"` 68 69 // ActionFunc is for simple actions with a return message for the user. 70 ActionFunc ActionFunc `json:"-"` 71 72 // DataFunc is for returning raw data that the caller for further processing. 73 DataFunc DataFunc `json:"-"` 74 75 // StructFunc is for returning any kind of struct. 76 StructFunc StructFunc `json:"-"` 77 78 // RecordFunc is for returning a database record. It will be properly locked 79 // and marshalled including metadata. 80 RecordFunc RecordFunc `json:"-"` 81 82 // HandlerFunc is the raw http handler. 83 HandlerFunc http.HandlerFunc `json:"-"` 84 } 85 86 // Parameter describes a parameterized variation of an endpoint. 87 type Parameter struct { 88 Method string 89 Field string 90 Value string 91 Description string 92 } 93 94 // HTTPStatusProvider is an interface for errors to provide a custom HTTP 95 // status code. 96 type HTTPStatusProvider interface { 97 HTTPStatus() int 98 } 99 100 // HTTPStatusError represents an error with an HTTP status code. 101 type HTTPStatusError struct { 102 err error 103 code int 104 } 105 106 // Error returns the error message. 107 func (e *HTTPStatusError) Error() string { 108 return e.err.Error() 109 } 110 111 // Unwrap return the wrapped error. 112 func (e *HTTPStatusError) Unwrap() error { 113 return e.err 114 } 115 116 // HTTPStatus returns the HTTP status code this error. 117 func (e *HTTPStatusError) HTTPStatus() int { 118 return e.code 119 } 120 121 // ErrorWithStatus adds the HTTP status code to the error. 122 func ErrorWithStatus(err error, code int) error { 123 return &HTTPStatusError{ 124 err: err, 125 code: code, 126 } 127 } 128 129 type ( 130 // ActionFunc is for simple actions with a return message for the user. 131 ActionFunc func(ar *Request) (msg string, err error) 132 133 // DataFunc is for returning raw data that the caller for further processing. 134 DataFunc func(ar *Request) (data []byte, err error) 135 136 // StructFunc is for returning any kind of struct. 137 StructFunc func(ar *Request) (i interface{}, err error) 138 139 // RecordFunc is for returning a database record. It will be properly locked 140 // and marshalled including metadata. 141 RecordFunc func(ar *Request) (r record.Record, err error) 142 ) 143 144 // MIME Types. 145 const ( 146 MimeTypeJSON string = "application/json" 147 MimeTypeText string = "text/plain" 148 149 apiV1Path = "/api/v1/" 150 ) 151 152 func init() { 153 RegisterHandler(apiV1Path+"{endpointPath:.+}", &endpointHandler{}) 154 } 155 156 var ( 157 endpoints = make(map[string]*Endpoint) 158 endpointsMux = mux.NewRouter() 159 endpointsLock sync.RWMutex 160 161 // ErrInvalidEndpoint is returned when an invalid endpoint is registered. 162 ErrInvalidEndpoint = errors.New("endpoint is invalid") 163 164 // ErrAlreadyRegistered is returned when there already is an endpoint with 165 // the same path registered. 166 ErrAlreadyRegistered = errors.New("an endpoint for this path is already registered") 167 ) 168 169 func getAPIContext(r *http.Request) (apiEndpoint *Endpoint, apiRequest *Request) { 170 // Get request context and check if we already have an action cached. 171 apiRequest = GetAPIRequest(r) 172 if apiRequest == nil { 173 return nil, nil 174 } 175 var ok bool 176 apiEndpoint, ok = apiRequest.HandlerCache.(*Endpoint) 177 if ok { 178 return apiEndpoint, apiRequest 179 } 180 181 endpointsLock.RLock() 182 defer endpointsLock.RUnlock() 183 184 // Get handler for request. 185 // Gorilla does not support handling this on our own very well. 186 // See github.com/gorilla/mux.ServeHTTP for reference. 187 var match mux.RouteMatch 188 var handler http.Handler 189 if endpointsMux.Match(r, &match) { 190 handler = match.Handler 191 apiRequest.Route = match.Route 192 // Add/Override variables instead of replacing. 193 for k, v := range match.Vars { 194 apiRequest.URLVars[k] = v 195 } 196 } else { 197 return nil, apiRequest 198 } 199 200 apiEndpoint, ok = handler.(*Endpoint) 201 if ok { 202 // Cache for next operation. 203 apiRequest.HandlerCache = apiEndpoint 204 } 205 return apiEndpoint, apiRequest 206 } 207 208 // RegisterEndpoint registers a new endpoint. An error will be returned if it 209 // does not pass the sanity checks. 210 func RegisterEndpoint(e Endpoint) error { 211 if err := e.check(); err != nil { 212 return fmt.Errorf("%w: %w", ErrInvalidEndpoint, err) 213 } 214 215 endpointsLock.Lock() 216 defer endpointsLock.Unlock() 217 218 _, ok := endpoints[e.Path] 219 if ok { 220 return ErrAlreadyRegistered 221 } 222 223 endpoints[e.Path] = &e 224 endpointsMux.Handle(apiV1Path+e.Path, &e) 225 return nil 226 } 227 228 // GetEndpointByPath returns the endpoint registered with the given path. 229 func GetEndpointByPath(path string) (*Endpoint, error) { 230 endpointsLock.Lock() 231 defer endpointsLock.Unlock() 232 endpoint, ok := endpoints[path] 233 if !ok { 234 return nil, fmt.Errorf("no registered endpoint on path: %q", path) 235 } 236 237 return endpoint, nil 238 } 239 240 func (e *Endpoint) check() error { 241 // Check path. 242 if strings.TrimSpace(e.Path) == "" { 243 return errors.New("path is missing") 244 } 245 246 // Check permissions. 247 if e.Read < Dynamic || e.Read > PermitSelf { 248 return errors.New("invalid read permission") 249 } 250 if e.Write < Dynamic || e.Write > PermitSelf { 251 return errors.New("invalid write permission") 252 } 253 254 // Check methods. 255 if e.Read != NotSupported { 256 switch e.ReadMethod { 257 case http.MethodGet: 258 // All good. 259 case "": 260 // Set to default. 261 e.ReadMethod = http.MethodGet 262 default: 263 return errors.New("invalid read method") 264 } 265 } else { 266 e.ReadMethod = "" 267 } 268 if e.Write != NotSupported { 269 switch e.WriteMethod { 270 case http.MethodPost, 271 http.MethodPut, 272 http.MethodDelete: 273 // All good. 274 case "": 275 // Set to default. 276 e.WriteMethod = http.MethodPost 277 default: 278 return errors.New("invalid write method") 279 } 280 } else { 281 e.WriteMethod = "" 282 } 283 284 // Check functions. 285 var defaultMimeType string 286 fnCnt := 0 287 if e.ActionFunc != nil { 288 fnCnt++ 289 defaultMimeType = MimeTypeText 290 } 291 if e.DataFunc != nil { 292 fnCnt++ 293 defaultMimeType = MimeTypeText 294 } 295 if e.StructFunc != nil { 296 fnCnt++ 297 defaultMimeType = MimeTypeJSON 298 } 299 if e.RecordFunc != nil { 300 fnCnt++ 301 defaultMimeType = MimeTypeJSON 302 } 303 if e.HandlerFunc != nil { 304 fnCnt++ 305 defaultMimeType = MimeTypeText 306 } 307 if fnCnt != 1 { 308 return errors.New("only one function may be set") 309 } 310 311 // Set default mime type. 312 if e.MimeType == "" { 313 e.MimeType = defaultMimeType 314 } 315 316 return nil 317 } 318 319 // ExportEndpoints exports the registered endpoints. The returned data must be 320 // treated as immutable. 321 func ExportEndpoints() []*Endpoint { 322 endpointsLock.RLock() 323 defer endpointsLock.RUnlock() 324 325 // Copy the map into a slice. 326 eps := make([]*Endpoint, 0, len(endpoints)) 327 for _, ep := range endpoints { 328 eps = append(eps, ep) 329 } 330 331 sort.Sort(sortByPath(eps)) 332 return eps 333 } 334 335 type sortByPath []*Endpoint 336 337 func (eps sortByPath) Len() int { return len(eps) } 338 func (eps sortByPath) Less(i, j int) bool { return eps[i].Path < eps[j].Path } 339 func (eps sortByPath) Swap(i, j int) { eps[i], eps[j] = eps[j], eps[i] } 340 341 type endpointHandler struct{} 342 343 var _ AuthenticatedHandler = &endpointHandler{} // Compile time interface check. 344 345 // ReadPermission returns the read permission for the handler. 346 func (eh *endpointHandler) ReadPermission(r *http.Request) Permission { 347 apiEndpoint, _ := getAPIContext(r) 348 if apiEndpoint != nil { 349 return apiEndpoint.Read 350 } 351 return NotFound 352 } 353 354 // WritePermission returns the write permission for the handler. 355 func (eh *endpointHandler) WritePermission(r *http.Request) Permission { 356 apiEndpoint, _ := getAPIContext(r) 357 if apiEndpoint != nil { 358 return apiEndpoint.Write 359 } 360 return NotFound 361 } 362 363 // ServeHTTP handles the http request. 364 func (eh *endpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 365 apiEndpoint, apiRequest := getAPIContext(r) 366 if apiEndpoint == nil || apiRequest == nil { 367 http.NotFound(w, r) 368 return 369 } 370 371 apiEndpoint.ServeHTTP(w, r) 372 } 373 374 // ServeHTTP handles the http request. 375 func (e *Endpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { 376 _, apiRequest := getAPIContext(r) 377 if apiRequest == nil { 378 http.NotFound(w, r) 379 return 380 } 381 382 // Wait for the owning module to be ready. 383 if !moduleIsReady(e.BelongsTo) { 384 http.Error(w, "The API endpoint is not ready yet or the its module is not enabled. Reload (F5) to try again.", http.StatusServiceUnavailable) 385 return 386 } 387 388 // Return OPTIONS request before starting to handle normal requests. 389 if r.Method == http.MethodOptions { 390 w.WriteHeader(http.StatusNoContent) 391 return 392 } 393 394 eMethod, readMethod, ok := getEffectiveMethod(r) 395 if !ok { 396 http.Error(w, "unsupported method for the actions API", http.StatusMethodNotAllowed) 397 return 398 } 399 400 if readMethod { 401 if eMethod != e.ReadMethod { 402 log.Tracer(r.Context()).Warningf( 403 "api: method %q does not match required read method %q%s", 404 r.Method, 405 e.ReadMethod, 406 " - this will be an error and abort the request in the future", 407 ) 408 } 409 } else { 410 if eMethod != e.WriteMethod { 411 log.Tracer(r.Context()).Warningf( 412 "api: method %q does not match required write method %q%s", 413 r.Method, 414 e.WriteMethod, 415 " - this will be an error and abort the request in the future", 416 ) 417 } 418 } 419 420 switch eMethod { 421 case http.MethodGet, http.MethodDelete: 422 // Nothing to do for these. 423 case http.MethodPost, http.MethodPut: 424 // Read body data. 425 inputData, ok := readBody(w, r) 426 if !ok { 427 return 428 } 429 apiRequest.InputData = inputData 430 431 // restore request body for any http.HandlerFunc below 432 r.Body = io.NopCloser(bytes.NewReader(inputData)) 433 default: 434 // Defensive. 435 http.Error(w, "unsupported method for the actions API", http.StatusMethodNotAllowed) 436 return 437 } 438 439 // Add response headers to request struct so that the endpoint can work with them. 440 apiRequest.ResponseHeader = w.Header() 441 442 // Execute action function and get response data 443 var responseData []byte 444 var err error 445 446 switch { 447 case e.ActionFunc != nil: 448 var msg string 449 msg, err = e.ActionFunc(apiRequest) 450 if !strings.HasSuffix(msg, "\n") { 451 msg += "\n" 452 } 453 if err == nil { 454 responseData = []byte(msg) 455 } 456 457 case e.DataFunc != nil: 458 responseData, err = e.DataFunc(apiRequest) 459 460 case e.StructFunc != nil: 461 var v interface{} 462 v, err = e.StructFunc(apiRequest) 463 if err == nil && v != nil { 464 var mimeType string 465 responseData, mimeType, _, err = dsd.MimeDump(v, r.Header.Get("Accept")) 466 if err == nil { 467 w.Header().Set("Content-Type", mimeType) 468 } 469 } 470 471 case e.RecordFunc != nil: 472 var rec record.Record 473 rec, err = e.RecordFunc(apiRequest) 474 if err == nil && r != nil { 475 responseData, err = MarshalRecord(rec, false) 476 } 477 478 case e.HandlerFunc != nil: 479 e.HandlerFunc(w, r) 480 return 481 482 default: 483 http.Error(w, "missing handler", http.StatusInternalServerError) 484 return 485 } 486 487 // Check for handler error. 488 if err != nil { 489 var statusProvider HTTPStatusProvider 490 if errors.As(err, &statusProvider) { 491 http.Error(w, err.Error(), statusProvider.HTTPStatus()) 492 } else { 493 http.Error(w, err.Error(), http.StatusInternalServerError) 494 } 495 return 496 } 497 498 // Return no content if there is none, or if request is HEAD. 499 if len(responseData) == 0 || r.Method == http.MethodHead { 500 w.WriteHeader(http.StatusNoContent) 501 return 502 } 503 504 // Set content type if not yet set. 505 if w.Header().Get("Content-Type") == "" { 506 w.Header().Set("Content-Type", e.MimeType+"; charset=utf-8") 507 } 508 509 // Write response. 510 w.Header().Set("Content-Length", strconv.Itoa(len(responseData))) 511 w.WriteHeader(http.StatusOK) 512 _, err = w.Write(responseData) 513 if err != nil { 514 log.Tracer(r.Context()).Warningf("api: failed to write response: %s", err) 515 } 516 } 517 518 func readBody(w http.ResponseWriter, r *http.Request) (inputData []byte, ok bool) { 519 // Check for too long content in order to prevent death. 520 if r.ContentLength > 20000000 { // 20MB 521 http.Error(w, "too much input data", http.StatusRequestEntityTooLarge) 522 return nil, false 523 } 524 525 // Read and close body. 526 inputData, err := io.ReadAll(r.Body) 527 if err != nil { 528 http.Error(w, "failed to read body"+err.Error(), http.StatusInternalServerError) 529 return nil, false 530 } 531 return inputData, true 532 }