github.hscsec.cn/aerogo/aero@v1.0.0/Context.go (about) 1 package aero 2 3 import ( 4 "bytes" 5 "compress/gzip" 6 "fmt" 7 "io" 8 "io/ioutil" 9 "log" 10 "mime" 11 "net/http" 12 "path/filepath" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/aerogo/session" 18 "github.com/fatih/color" 19 jsoniter "github.com/json-iterator/go" 20 "github.com/julienschmidt/httprouter" 21 "github.com/tomasen/realip" 22 ) 23 24 // This should be close to the MTU size of a TCP packet. 25 // Regarding performance it makes no sense to compress smaller files. 26 // Bandwidth can be saved however the savings are minimal for small files 27 // and the overhead of compressing can lead up to a 75% reduction 28 // in server speed under high load. Therefore in this case 29 // we're trying to optimize for performance, not bandwidth. 30 const gzipThreshold = 1450 31 32 const ( 33 cacheControlHeader = "Cache-Control" 34 cacheControlAlwaysValidate = "must-revalidate" 35 cacheControlMedia = "public, max-age=13824000" 36 contentTypeOptionsHeader = "X-Content-Type-Options" 37 contentTypeOptions = "nosniff" 38 xssProtectionHeader = "X-XSS-Protection" 39 xssProtection = "1; mode=block" 40 etagHeader = "ETag" 41 contentTypeHeader = "Content-Type" 42 contentTypeHTML = "text/html; charset=utf-8" 43 contentTypeCSS = "text/css; charset=utf-8" 44 contentTypeJavaScript = "application/javascript; charset=utf-8" 45 contentTypeJSON = "application/json; charset=utf-8" 46 contentTypeJSONLD = "application/ld+json; charset=utf-8" 47 contentTypePlainText = "text/plain; charset=utf-8" 48 contentTypeEventStream = "text/event-stream; charset=utf-8" 49 contentEncodingHeader = "Content-Encoding" 50 contentEncodingGzip = "gzip" 51 acceptEncodingHeader = "Accept-Encoding" 52 contentLengthHeader = "Content-Length" 53 ifNoneMatchHeader = "If-None-Match" 54 referrerPolicyHeader = "Referrer-Policy" 55 referrerPolicySameOrigin = "no-referrer" 56 strictTransportSecurityHeader = "Strict-Transport-Security" 57 strictTransportSecurity = "max-age=31536000; includeSubDomains; preload" 58 contentSecurityPolicyHeader = "Content-Security-Policy" 59 60 // responseTimeHeader = "X-Response-Time" 61 // xFrameOptionsHeader = "X-Frame-Options" 62 // xFrameOptions = "SAMEORIGIN" 63 // serverHeader = "Server" 64 // server = "Aero" 65 ) 66 67 // Push options describes the headers that are sent 68 // to our server to retrieve the push response. 69 var pushOptions = http.PushOptions{ 70 Method: "GET", 71 Header: http.Header{ 72 acceptEncodingHeader: []string{"gzip"}, 73 }, 74 } 75 76 // Context represents a single request & response. 77 type Context struct { 78 // net/http 79 request *http.Request 80 response http.ResponseWriter 81 params httprouter.Params 82 83 // Responded tells if the request has been dealt with already 84 responded bool 85 86 // A pointer to the application this request occurred on. 87 App *Application 88 89 // Status code 90 StatusCode int 91 92 // Error message 93 ErrorMessage string 94 95 // Custom data 96 Data interface{} 97 98 // User session 99 session *session.Session 100 } 101 102 // Request returns the HTTP request. 103 func (ctx *Context) Request() Request { 104 return Request{ 105 inner: ctx.request, 106 } 107 } 108 109 // Response returns the HTTP response. 110 func (ctx *Context) Response() Response { 111 return Response{ 112 inner: ctx.response, 113 } 114 } 115 116 // Session returns the session of the context or creates and caches a new session. 117 func (ctx *Context) Session() *session.Session { 118 // Return cached session if available. 119 if ctx.session != nil { 120 return ctx.session 121 } 122 123 // Check if the client has a session cookie already. 124 cookie, err := ctx.request.Cookie("sid") 125 126 if err == nil { 127 sid := cookie.Value 128 129 if session.IsValidID(sid) { 130 ctx.session, err = ctx.App.Sessions.Store.Get(sid) 131 132 if err != nil { 133 color.Red(err.Error()) 134 } 135 136 if ctx.session != nil { 137 return ctx.session 138 } 139 } 140 } 141 142 // Create a new session 143 ctx.session = ctx.App.Sessions.New() 144 145 // Create a session cookie in the client 146 ctx.createSessionCookie() 147 148 return ctx.session 149 } 150 151 // createSessionCookie creates a session cookie in the client. 152 func (ctx *Context) createSessionCookie() { 153 sessionCookie := http.Cookie{ 154 Name: "sid", 155 Value: ctx.session.ID(), 156 HttpOnly: true, 157 Secure: true, 158 MaxAge: ctx.App.Sessions.Duration, 159 Path: "/", 160 } 161 162 http.SetCookie(ctx.response, &sessionCookie) 163 } 164 165 // HasSession indicates whether the client has a valid session or not. 166 func (ctx *Context) HasSession() bool { 167 if ctx.session != nil { 168 return true 169 } 170 171 cookie, err := ctx.request.Cookie("sid") 172 173 if err != nil || !session.IsValidID(cookie.Value) { 174 return false 175 } 176 177 ctx.session, err = ctx.App.Sessions.Store.Get(cookie.Value) 178 179 if err != nil { 180 return false 181 } 182 183 return ctx.session != nil 184 } 185 186 // JSON encodes the object to a JSON string and responds. 187 func (ctx *Context) JSON(value interface{}) string { 188 ctx.response.Header().Set(contentTypeHeader, contentTypeJSON) 189 190 bytes, err := jsoniter.Marshal(value) 191 192 if err != nil { 193 ctx.StatusCode = http.StatusInternalServerError 194 return `{"error": "Could not encode object to JSON"}` 195 } 196 197 return string(bytes) 198 } 199 200 // JSONLinkedData encodes the object to a JSON linked data string and responds. 201 func (ctx *Context) JSONLinkedData(value interface{}) string { 202 ctx.response.Header().Set(contentTypeHeader, contentTypeJSONLD) 203 204 bytes, err := jsoniter.Marshal(value) 205 206 if err != nil { 207 ctx.StatusCode = http.StatusInternalServerError 208 return `{"error": "Could not encode object to JSON"}` 209 } 210 211 return string(bytes) 212 } 213 214 // HTML sends a HTML string. 215 func (ctx *Context) HTML(html string) string { 216 ctx.response.Header().Set(contentTypeHeader, contentTypeHTML) 217 ctx.response.Header().Set(contentTypeOptionsHeader, contentTypeOptions) 218 ctx.response.Header().Set(xssProtectionHeader, xssProtection) 219 // ctx.response.Header().Set(xFrameOptionsHeader, xFrameOptions) 220 ctx.response.Header().Set(referrerPolicyHeader, referrerPolicySameOrigin) 221 222 if ctx.App.Security.Certificate != "" { 223 ctx.response.Header().Set(strictTransportSecurityHeader, strictTransportSecurity) 224 ctx.response.Header().Set(contentSecurityPolicyHeader, ctx.App.ContentSecurityPolicy.String()) 225 } 226 227 return html 228 } 229 230 // Text sends a plain text string. 231 func (ctx *Context) Text(text string) string { 232 ctx.response.Header().Set(contentTypeHeader, contentTypePlainText) 233 return text 234 } 235 236 // CSS sends a style sheet. 237 func (ctx *Context) CSS(text string) string { 238 ctx.response.Header().Set(contentTypeHeader, contentTypeCSS) 239 return text 240 } 241 242 // JavaScript sends a script. 243 func (ctx *Context) JavaScript(code string) string { 244 ctx.response.Header().Set(contentTypeHeader, contentTypeJavaScript) 245 return code 246 } 247 248 // EventStream sends server events to the client. 249 func (ctx *Context) EventStream(stream *EventStream) string { 250 defer close(stream.Closed) 251 252 // Flush 253 flusher, ok := ctx.response.(http.Flusher) 254 255 if !ok { 256 return ctx.Error(http.StatusNotImplemented, "Flushing not supported") 257 } 258 259 // Catch disconnect events 260 disconnected := ctx.request.Context().Done() 261 262 // Send headers 263 header := ctx.response.Header() 264 header.Set(contentTypeHeader, contentTypeEventStream) 265 header.Set(cacheControlHeader, "no-cache") 266 header.Set("Connection", "keep-alive") 267 header.Set("Access-Control-Allow-Origin", "*") 268 ctx.response.WriteHeader(200) 269 ctx.responded = true 270 271 for { 272 select { 273 case <-disconnected: 274 return "" 275 276 case event := <-stream.Events: 277 if event != nil { 278 data := event.Data 279 280 switch data.(type) { 281 case string, []byte: 282 // Do nothing with the data if it's already a string or byte slice. 283 default: 284 data, _ = jsoniter.Marshal(data) 285 } 286 287 fmt.Fprintf(ctx.response, "event: %s\ndata: %s\n\n", event.Name, data) 288 flusher.Flush() 289 } 290 291 case <-time.After(5 * time.Second): 292 // Send one byte to keep alive the connection 293 // which will also check for disconnection. 294 ctx.response.Write([]byte("\n")) 295 flusher.Flush() 296 } 297 } 298 } 299 300 // File sends the contents of a local file and determines its mime type by extension. 301 func (ctx *Context) File(file string) string { 302 extension := filepath.Ext(file) 303 contentType := mime.TypeByExtension(extension) 304 305 // Cache control header 306 if IsMediaType(contentType) { 307 ctx.response.Header().Set(cacheControlHeader, cacheControlMedia) 308 } 309 310 http.ServeFile(ctx.response, ctx.request, file) 311 ctx.responded = true 312 return "" 313 } 314 315 // ReadAll returns the contents of the reader. 316 // This will create an in-memory copy and calculate the E-Tag before sending the data. 317 // Compression will be applied if necessary. 318 func (ctx *Context) ReadAll(reader io.Reader) string { 319 data, err := ioutil.ReadAll(reader) 320 321 if err != nil { 322 return ctx.Error(http.StatusInternalServerError, err) 323 } 324 325 return BytesToStringUnsafe(data) 326 } 327 328 // Reader sends the contents of the io.Reader without creating an in-memory copy. 329 // E-Tags will not be generated for the content and compression will not be applied. 330 // Use this function if your reader contains huge amounts of data. 331 func (ctx *Context) Reader(reader io.Reader) string { 332 io.Copy(ctx.response, reader) 333 ctx.responded = true 334 return "" 335 } 336 337 // ReadSeeker sends the contents of the io.ReadSeeker without creating an in-memory copy. 338 // E-Tags will not be generated for the content and compression will not be applied. 339 // Use this function if your reader contains huge amounts of data. 340 func (ctx *Context) ReadSeeker(reader io.ReadSeeker) string { 341 http.ServeContent(ctx.response, ctx.request, "", time.Time{}, reader) 342 ctx.responded = true 343 return "" 344 } 345 346 // Error should be used for sending error messages to the user. 347 func (ctx *Context) Error(statusCode int, errors ...interface{}) string { 348 ctx.StatusCode = statusCode 349 ctx.response.Header().Set(contentTypeHeader, contentTypeHTML) 350 351 message := bytes.Buffer{} 352 353 if len(errors) == 0 { 354 message.WriteString(fmt.Sprintf("Unknown error: %d", statusCode)) 355 } else { 356 for index, param := range errors { 357 switch err := param.(type) { 358 case string: 359 message.WriteString(err) 360 case error: 361 message.WriteString(err.Error()) 362 default: 363 continue 364 } 365 366 if index != len(errors)-1 { 367 message.WriteString(": ") 368 } 369 } 370 } 371 372 ctx.ErrorMessage = message.String() 373 color.Red(ctx.ErrorMessage) 374 return ctx.ErrorMessage 375 } 376 377 // URI returns the relative path, e.g. /blog/post/123. 378 func (ctx *Context) URI() string { 379 return ctx.request.URL.Path 380 } 381 382 // SetURI sets the relative path, e.g. /blog/post/123. 383 func (ctx *Context) SetURI(b string) { 384 ctx.request.URL.Path = b 385 } 386 387 // Get retrieves an URL parameter. 388 func (ctx *Context) Get(param string) string { 389 return strings.TrimPrefix(ctx.params.ByName(param), "/") 390 } 391 392 // GetInt retrieves an URL parameter as an integer. 393 func (ctx *Context) GetInt(param string) (int, error) { 394 return strconv.Atoi(ctx.Get(param)) 395 } 396 397 // RealIP tries to determine the real IP address of the request. 398 func (ctx *Context) RealIP() string { 399 return strings.Trim(realip.RealIP(ctx.request), "[]") 400 } 401 402 // UserAgent retrieves the user agent for the given request. 403 func (ctx *Context) UserAgent() string { 404 ctx.request.URL.Query() 405 return ctx.request.UserAgent() 406 } 407 408 // Query retrieves the value for the given URL query parameter. 409 func (ctx *Context) Query(param string) string { 410 return ctx.request.URL.Query().Get(param) 411 } 412 413 // Redirect redirects to the given URL using status code 302. 414 func (ctx *Context) Redirect(url string) string { 415 ctx.StatusCode = http.StatusFound 416 ctx.response.Header().Set("Location", url) 417 return "" 418 } 419 420 // RedirectPermanently redirects to the given URL and indicates that this is a permanent change using status code 301. 421 func (ctx *Context) RedirectPermanently(url string) string { 422 ctx.StatusCode = http.StatusMovedPermanently 423 ctx.response.Header().Set("Location", url) 424 return "" 425 } 426 427 // IsMediaType returns whether the given content type is a media type. 428 func IsMediaType(contentType string) bool { 429 return strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") 430 } 431 432 // pushResources will push the given resources to the HTTP response. 433 func (ctx *Context) pushResources() { 434 // Check if all the conditions for a push are met 435 for _, pushCondition := range ctx.App.pushConditions { 436 if !pushCondition(ctx) { 437 return 438 } 439 } 440 441 // OnPush callbacks 442 for _, callback := range ctx.App.onPush { 443 callback(ctx) 444 } 445 446 // Check if we can push 447 pusher, ok := ctx.response.(http.Pusher) 448 449 if !ok { 450 return 451 } 452 453 // Push every resource defined in config.json 454 for _, resource := range ctx.App.Config.Push { 455 if err := pusher.Push(resource, &pushOptions); err != nil { 456 log.Printf("Failed to push %s: %v", resource, err) 457 } 458 } 459 } 460 461 // respond responds either with raw code or gzipped if the 462 // code length is greater than the gzip threshold. 463 func (ctx *Context) respond(code string) { 464 // If the request has been dealt with already, 465 // or if the request has been canceled by the client, 466 // there's nothing to do here. 467 if ctx.responded || ctx.request.Context().Err() != nil { 468 return 469 } 470 471 ctx.respondBytes(StringToBytesUnsafe(code)) 472 } 473 474 // respondBytes responds either with raw code or gzipped if the 475 // code length is greater than the gzip threshold. Requires a byte slice. 476 func (ctx *Context) respondBytes(b []byte) { 477 response := ctx.response 478 header := response.Header() 479 contentType := header.Get(contentTypeHeader) 480 isMedia := IsMediaType(contentType) 481 482 // Cache control header 483 if isMedia { 484 header.Set(cacheControlHeader, cacheControlMedia) 485 } else { 486 header.Set(cacheControlHeader, cacheControlAlwaysValidate) 487 } 488 489 // Push 490 if contentType == contentTypeHTML && len(ctx.App.Config.Push) > 0 { 491 ctx.pushResources() 492 } 493 494 // Small response 495 if len(b) < gzipThreshold { 496 header.Set(contentLengthHeader, strconv.Itoa(len(b))) 497 response.WriteHeader(ctx.StatusCode) 498 response.Write(b) 499 return 500 } 501 502 // ETag generation 503 etag := ETag(b) 504 505 // If client cache is up to date, send 304 with no response body. 506 clientETag := ctx.request.Header.Get(ifNoneMatchHeader) 507 508 if etag == clientETag { 509 response.WriteHeader(304) 510 return 511 } 512 513 // Set ETag 514 header.Set(etagHeader, etag) 515 516 // No GZip? 517 supportsGZip := strings.Contains(ctx.request.Header.Get(acceptEncodingHeader), "gzip") 518 519 if !ctx.App.Config.GZip || !supportsGZip || isMedia { 520 header.Set(contentLengthHeader, strconv.Itoa(len(b))) 521 response.WriteHeader(ctx.StatusCode) 522 response.Write(b) 523 return 524 } 525 526 // GZip 527 header.Set(contentEncodingHeader, contentEncodingGzip) 528 response.WriteHeader(ctx.StatusCode) 529 530 // Write response body 531 writer, _ := gzip.NewWriterLevel(response, gzip.BestCompression) 532 writer.Write(b) 533 writer.Flush() 534 }