diff --git a/HISTORY.md b/HISTORY.md index 0d78cb1a..6b81232e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -394,31 +394,33 @@ Other Improvements: New Context Methods: -- `context.IsHTTP2() bool` reports whether the protocol version for incoming request was HTTP/2 -- `context.IsGRPC() bool` reports whether the request came from a gRPC client -- `context.UpsertCookie(*http.Cookie, cookieOptions ...context.CookieOption)` upserts a cookie, fixes [#1485](https://github.com/kataras/iris/issues/1485) too -- `context.StopWithStatus(int)` stops the handlers chain and writes the status code -- `context.StopWithText(int, string)` stops the handlers chain, writes thre status code and a plain text message -- `context.StopWithError(int, error)` stops the handlers chain, writes thre status code and the error's message -- `context.StopWithJSON(int, interface{})` stops the handlers chain, writes the status code and sends a JSON response -- `context.StopWithProblem(int, iris.Problem)` stops the handlers, writes the status code and sends an `application/problem+json` response -- `context.Protobuf(proto.Message)` sends protobuf to the client -- `context.MsgPack(interface{})` sends msgpack format data to the client -- `context.ReadProtobuf(ptr)` binds request body to a proto message -- `context.ReadMsgPack(ptr)` binds request body of a msgpack format to a struct -- `context.ReadBody(ptr)` binds the request body to the "ptr" depending on the request's Method and Content-Type -- `context.SetSameSite(http.SameSite)` to set cookie "SameSite" option (respectful by sessions too) -- `context.Defer(Handler)` works like `Party.Done` but for the request life-cycle instead -- `context.ReflectValue() []reflect.Value` stores and returns the `[]reflect.ValueOf(context)` -- `context.Controller() reflect.Value` returns the current MVC Controller value. +- `Context.ServeContentWithRate`, `ServeFileWithRate` and `SendFileWithRate` methods to throttle the "download" speed of the client. +- `Context.IsHTTP2() bool` reports whether the protocol version for incoming request was HTTP/2 +- `Context.IsGRPC() bool` reports whether the request came from a gRPC client +- `Context.UpsertCookie(*http.Cookie, cookieOptions ...context.CookieOption)` upserts a cookie, fixes [#1485](https://github.com/kataras/iris/issues/1485) too +- `Context.StopWithStatus(int)` stops the handlers chain and writes the status code +- `Context.StopWithText(int, string)` stops the handlers chain, writes thre status code and a plain text message +- `Context.StopWithError(int, error)` stops the handlers chain, writes thre status code and the error's message +- `Context.StopWithJSON(int, interface{})` stops the handlers chain, writes the status code and sends a JSON response +- `Context.StopWithProblem(int, iris.Problem)` stops the handlers, writes the status code and sends an `application/problem+json` response +- `Context.Protobuf(proto.Message)` sends protobuf to the client +- `Context.MsgPack(interface{})` sends msgpack format data to the client +- `Context.ReadProtobuf(ptr)` binds request body to a proto message +- `Context.ReadMsgPack(ptr)` binds request body of a msgpack format to a struct +- `Context.ReadBody(ptr)` binds the request body to the "ptr" depending on the request's Method and Content-Type +- `Context.SetSameSite(http.SameSite)` to set cookie "SameSite" option (respectful by sessions too) +- `Context.Defer(Handler)` works like `Party.Done` but for the request life-cycle instead +- `Context.ReflectValue() []reflect.Value` stores and returns the `[]reflect.ValueOf(ctx)` +- `Context.Controller() reflect.Value` returns the current MVC Controller value. Breaking Changes: -Change the MIME type of `Javascript .js` and `JSONP` as the HTML specification now recommends to `"text/javascript"` instead of the obselete `"application/javascript"`. This change was pushed to the `Go` language itself as well. See . - +- Change the MIME type of `Javascript .js` and `JSONP` as the HTML specification now recommends to `"text/javascript"` instead of the obselete `"application/javascript"`. This change was pushed to the `Go` language itself as well. See . +- Remove the last input argument of `enableGzipCompression` in `Context.ServeContent`, `ServeFile` methods. This was deprecated a few versions ago. A middleware (`app.Use(iris.Gzip)`) or a prior call to `Context.Gzip(true)` will enable gzip compression. Also these two methods and `Context.SendFile` one now support `Content-Range` and `Accept-Ranges` correctly out of the box (`net/http` had a bug, which is now fixed). +- `Context.ServeContent` no longer returns an error, see `ServeContentWithRate`, `ServeFileWithRate` and `SendFileWithRate` new methods too. - `route.Trace() string` changed to `route.Trace(w io.Writer)`, to achieve the same result just pass a `bytes.Buffer` -- `var mvc.AutoBinding` removed as the default behavior now resolves such dependencies automatically (see [[FEATURE REQUEST] MVC serving gRPC-compatible controller](https://github.com/kataras/iris/issues/1449)) -- `mvc#Application.SortByNumMethods()` removed as the default behavior now binds the "thinnest" empty `interface{}` automatically (see [MVC: service injecting fails](https://github.com/kataras/iris/issues/1343)) +- `var mvc.AutoBinding` removed as the default behavior now resolves such dependencies automatically (see [[FEATURE REQUEST] MVC serving gRPC-compatible controller](https://github.com/kataras/iris/issues/1449)). +- `mvc#Application.SortByNumMethods()` removed as the default behavior now binds the "thinnest" empty `interface{}` automatically (see [MVC: service injecting fails](https://github.com/kataras/iris/issues/1343)). - `mvc#BeforeActivation.Dependencies().Add` should be replaced with `mvc#BeforeActivation.Dependencies().Register` instead - **REMOVE** the `kataras/iris/v12/typescript` package in favor of the new [iris-cli](https://github.com/kataras/iris-cli). Also, the alm typescript online editor was removed as it is deprecated by its author, please consider using the [designtsx](https://designtsx.com/) instead. diff --git a/_examples/README.md b/_examples/README.md index 723f27dd..cae94200 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -237,7 +237,7 @@ You can serve [quicktemplate](https://github.com/valyala/quicktemplate) and [her - [Basic](file-server/basic/main.go) - [Embedding Files Into App Executable File](file-server/embedding-files-into-app/main.go) - [Embedding Gziped Files Into App Executable File](file-server/embedding-gziped-files-into-app/main.go) -- [Send/Force-Download Files](file-server/send-files/main.go) +- [Send/Force-Download Files](file-server/send-files/main.go) **UPDATED** - Single Page Applications * [single Page Application](file-server/single-page-application/basic/main.go) * [embedded Single Page Application](file-server/single-page-application/embedded-single-page-application/main.go) @@ -246,7 +246,7 @@ You can serve [quicktemplate](https://github.com/valyala/quicktemplate) and [her ### How to Read from `context.Request() *http.Request` - [Read JSON](http_request/read-json/main.go) - * [Struct Validation](http_request/read-json-struct-validation/main.go) **UPDaTE** + * [Struct Validation](http_request/read-json-struct-validation/main.go) **UPDATED** - [Read XML](http_request/read-xml/main.go) - [Read MsgPack](http_request/read-msgpack/main.go) **NEW** - [Read YAML](http_request/read-yaml/main.go) diff --git a/_examples/file-server/send-files/files/first.zip b/_examples/file-server/send-files/files/first.zip index 431fa5fd..c98f5ac8 100644 Binary files a/_examples/file-server/send-files/files/first.zip and b/_examples/file-server/send-files/files/first.zip differ diff --git a/_examples/file-server/send-files/main.go b/_examples/file-server/send-files/main.go index bc03b0a1..3e5c46a1 100644 --- a/_examples/file-server/send-files/main.go +++ b/_examples/file-server/send-files/main.go @@ -6,11 +6,26 @@ import ( func main() { app := iris.New() + app.Logger().SetLevel("debug") - app.Get("/", func(ctx iris.Context) { - file := "./files/first.zip" - ctx.SendFile(file, "c.zip") - }) + app.Get("/", download) + app.Get("/download", downloadWithRateLimit) app.Listen(":8080") } + +func download(ctx iris.Context) { + src := "./files/first.zip" + ctx.SendFile(src, "client.zip") +} + +func downloadWithRateLimit(ctx iris.Context) { + // REPLACE THAT WITH A BIG LOCAL FILE OF YOUR OWN. + src := "./files/first.zip" + dest := "" /* optionally, keep it empty to resolve the filename based on the "src" */ + + // Limit download speed to ~50Kb/s with a burst of 100KB. + limit := 50.0 * iris.KB + burst := 100 * iris.KB + ctx.SendFileWithRate(src, dest, limit, burst) +} diff --git a/_examples/http_responsewriter/write-gzip/main.go b/_examples/http_responsewriter/write-gzip/main.go index f1d6369d..080e4164 100644 --- a/_examples/http_responsewriter/write-gzip/main.go +++ b/_examples/http_responsewriter/write-gzip/main.go @@ -4,6 +4,9 @@ import "github.com/kataras/iris/v12" func main() { app := iris.New() + // app.Use(iris.Gzip) + // func(ctx iris.Context) { ctx.Gzip(true/false)} + // OR: app.Get("/", func(ctx iris.Context) { ctx.WriteGzip([]byte("Hello World!")) ctx.Header("X-Custom", diff --git a/context/context.go b/context/context.go index 18e57cf5..2eff50dc 100644 --- a/context/context.go +++ b/context/context.go @@ -2,6 +2,7 @@ package context import ( "bytes" + stdContext "context" "encoding/json" "encoding/xml" "errors" @@ -35,6 +36,7 @@ import ( jsoniter "github.com/json-iterator/go" "github.com/microcosm-cc/bluemonday" "github.com/vmihailenco/msgpack/v5" + "golang.org/x/time/rate" "gopkg.in/yaml.v3" ) @@ -916,31 +918,57 @@ type Context interface { // | Serve files | // +------------------------------------------------------------+ - // ServeContent serves content, headers are autoset - // receives three parameters, it's low-level function, instead you can use .ServeFile(string,bool)/SendFile(string,string) + // ServeContent replies to the request using the content in the + // provided ReadSeeker. The main benefit of ServeContent over io.Copy + // is that it handles Range requests properly, sets the MIME type, and + // handles If-Match, If-Unmodified-Since, If-None-Match, If-Modified-Since, + // and If-Range requests. // + // If the response's Content-Type header is not set, ServeContent + // first tries to deduce the type from name's file extension. // - // You can define your own "Content-Type" with `context#ContentType`, before this function call. + // The name is otherwise unused; in particular it can be empty and is + // never sent in the response. // - // This function doesn't support resuming (by range), - // use ctx.SendFile or router's `HandleDir` instead. - ServeContent(content io.ReadSeeker, filename string, modtime time.Time, gzipCompression bool) error - // ServeFile serves a file (to send a file, a zip for example to the client you should use the `SendFile` instead) - // receives two parameters - // filename/path (string) - // gzipCompression (bool) + // If modtime is not the zero time or Unix epoch, ServeContent + // includes it in a Last-Modified header in the response. If the + // request includes an If-Modified-Since header, ServeContent uses + // modtime to decide whether the content needs to be sent at all. // - // You can define your own "Content-Type" with `context#ContentType`, before this function call. + // The content's Seek method must work: ServeContent uses + // a seek to the end of the content to determine its size. // - // This function doesn't support resuming (by range), - // use ctx.SendFile or router's `HandleDir` instead. + // If the caller has set w's ETag header formatted per RFC 7232, section 2.3, + // ServeContent uses it to handle requests using If-Match, If-None-Match, or If-Range. // - // Use it when you want to serve dynamic files to the client. - ServeFile(filename string, gzipCompression bool) error - // SendFile sends file for force-download to the client + // Note that *os.File implements the io.ReadSeeker interface. + // Note that gzip compression can be registered through `ctx.Gzip(true)` or `app.Use(iris.Gzip)`. + ServeContent(content io.ReadSeeker, filename string, modtime time.Time) + // ServeContentWithRate same as `ServeContent` but it can throttle the speed of reading + // and though writing the "content" to the client. + ServeContentWithRate(content io.ReadSeeker, filename string, modtime time.Time, limit float64, burst int) + // ServeFile replies to the request with the contents of the named + // file or directory. // - // Use this instead of ServeFile to 'force-download' bigger files to the client. + // If the provided file or directory name is a relative path, it is + // interpreted relative to the current directory and may ascend to + // parent directories. If the provided name is constructed from user + // input, it should be sanitized before calling `ServeFile`. + // + // Use it when you want to serve assets like css and javascript files. + // If client should confirm and save the file use the `SendFile` instead. + // Note that gzip compression can be registered through `ctx.Gzip(true)` or `app.Use(iris.Gzip)`. + ServeFile(filename string) error + // ServeFileWithRate same as `ServeFile` but it can throttle the speed of reading + // and though writing the file to the client. + ServeFileWithRate(filename string, limit float64, burst int) error + // SendFile sends a file as an attachment, that is downloaded and saved locally from client. + // Note that gzip compression can be registered through `ctx.Gzip(true)` or `app.Use(iris.Gzip)`. + // Use `ServeFile` if a file should be served as a page asset instead. SendFile(filename string, destinationName string) error + // SendFileWithRate same as `SendFile` but it can throttle the speed of reading + // and though writing the file to the client. + SendFileWithRate(src, destName string, limit float64, burst int) error // +------------------------------------------------------------+ // | Cookies | @@ -4544,65 +4572,135 @@ func (n *NegotiationAcceptBuilder) EncodingGzip() *NegotiationAcceptBuilder { // | Serve files | // +------------------------------------------------------------+ -// ServeContent serves content, headers are autoset -// receives three parameters, it's low-level function, instead you can use .ServeFile(string,bool)/SendFile(string,string) +// ServeContent replies to the request using the content in the +// provided ReadSeeker. The main benefit of ServeContent over io.Copy +// is that it handles Range requests properly, sets the MIME type, and +// handles If-Match, If-Unmodified-Since, If-None-Match, If-Modified-Since, +// and If-Range requests. // -// You can define your own "Content-Type" header also, after this function call -// Doesn't implements resuming (by range), use ctx.SendFile instead -func (ctx *context) ServeContent(content io.ReadSeeker, filename string, modtime time.Time, gzipCompression bool) error { - if modified, err := ctx.CheckIfModifiedSince(modtime); !modified && err == nil { - ctx.WriteNotModified() - return nil +// If the response's Content-Type header is not set, ServeContent +// first tries to deduce the type from name's file extension. +// +// The name is otherwise unused; in particular it can be empty and is +// never sent in the response. +// +// If modtime is not the zero time or Unix epoch, ServeContent +// includes it in a Last-Modified header in the response. If the +// request includes an If-Modified-Since header, ServeContent uses +// modtime to decide whether the content needs to be sent at all. +// +// The content's Seek method must work: ServeContent uses +// a seek to the end of the content to determine its size. +// +// If the caller has set w's ETag header formatted per RFC 7232, section 2.3, +// ServeContent uses it to handle requests using If-Match, If-None-Match, or If-Range. +// +// Note that *os.File implements the io.ReadSeeker interface. +// Note that gzip compression can be registered through `ctx.Gzip(true)` or `app.Use(iris.Gzip)`. +func (ctx *context) ServeContent(content io.ReadSeeker, filename string, modtime time.Time) { + ctx.ServeContentWithRate(content, filename, modtime, 0, 0) +} + +// rateReadSeeker is a io.ReadSeeker that is rate limited by +// the given token bucket. Each token in the bucket +// represents one byte. See "golang.org/x/time/rate" package. +type rateReadSeeker struct { + io.ReadSeeker + ctx stdContext.Context + limiter *rate.Limiter +} + +func (rs *rateReadSeeker) Read(buf []byte) (int, error) { + n, err := rs.ReadSeeker.Read(buf) + if n <= 0 { + return n, err + } + rs.limiter.WaitN(rs.ctx, n) + return n, err +} + +// ServeContentWithRate same as `ServeContent` but it can throttle the speed of reading +// and though writing the "content" to the client. +func (ctx *context) ServeContentWithRate(content io.ReadSeeker, filename string, modtime time.Time, limit float64, burst int) { + if limit > 0 { + content = &rateReadSeeker{ + ReadSeeker: content, + ctx: ctx.request.Context(), + limiter: rate.NewLimiter(rate.Limit(limit), burst), + } } if ctx.GetContentType() == "" { ctx.ContentType(filename) } - ctx.SetLastModified(modtime) - var out io.Writer - if gzipCompression && ctx.ClientSupportsGzip() { - AddGzipHeaders(ctx.writer) - - gzipWriter := acquireGzipWriter(ctx.writer) - defer releaseGzipWriter(gzipWriter) - out = gzipWriter - } else { - out = ctx.writer - } - _, err := io.Copy(out, content) - return err ///TODO: add an int64 as return value for the content length written like other writers or let it as it's in order to keep the stable api? + http.ServeContent(ctx.writer, ctx.request, filename, modtime, content) } -// ServeFile serves a view file, to send a file ( zip for example) to the client you should use the SendFile(serverfilename,clientfilename) -// receives two parameters -// filename/path (string) -// gzipCompression (bool) +// ServeFile replies to the request with the contents of the named +// file or directory. // -// You can define your own "Content-Type" header also, after this function call -// This function doesn't implement resuming (by range), use ctx.SendFile instead +// If the provided file or directory name is a relative path, it is +// interpreted relative to the current directory and may ascend to +// parent directories. If the provided name is constructed from user +// input, it should be sanitized before calling `ServeFile`. // -// Use it when you want to serve css/js/... files to the client, for bigger files and 'force-download' use the SendFile. -func (ctx *context) ServeFile(filename string, gzipCompression bool) error { +// Use it when you want to serve assets like css and javascript files. +// If client should confirm and save the file use the `SendFile` instead. +// Note that gzip compression can be registered through `ctx.Gzip(true)` or `app.Use(iris.Gzip)`. +func (ctx *context) ServeFile(filename string) error { + return ctx.ServeFileWithRate(filename, 0, 0) +} + +// ServeFileWithRate same as `ServeFile` but it can throttle the speed of reading +// and though writing the file to the client. +func (ctx *context) ServeFileWithRate(filename string, limit float64, burst int) error { f, err := os.Open(filename) if err != nil { - return fmt.Errorf("%d", http.StatusNotFound) + ctx.StatusCode(http.StatusNotFound) + return err } defer f.Close() - fi, _ := f.Stat() - if fi.IsDir() { - return ctx.ServeFile(path.Join(filename, "index.html"), gzipCompression) + + st, err := f.Stat() + if err != nil { + code := http.StatusInternalServerError + if os.IsNotExist(err) { + code = http.StatusNotFound + } + + if os.IsPermission(err) { + code = http.StatusForbidden + } + + ctx.StatusCode(code) + return err } - return ctx.ServeContent(f, fi.Name(), fi.ModTime(), gzipCompression) + if st.IsDir() { + return ctx.ServeFile(path.Join(filename, "index.html")) + } + + ctx.ServeContentWithRate(f, st.Name(), st.ModTime(), limit, burst) + return nil } -// SendFile sends file for force-download to the client -// -// Use this instead of ServeFile to 'force-download' bigger files to the client. -func (ctx *context) SendFile(filename string, destinationName string) error { - ctx.writer.Header().Set(ContentDispositionHeaderKey, "attachment;filename="+destinationName) - return ctx.ServeFile(filename, false) +// SendFile sends a file as an attachment, that is downloaded and saved locally from client. +// Note that gzip compression can be registered through `ctx.Gzip(true)` or `app.Use(iris.Gzip)`. +// Use `ServeFile` if a file should be served as a page asset instead. +func (ctx *context) SendFile(src string, destName string) error { + return ctx.SendFileWithRate(src, destName, 0, 0) +} + +// SendFileWithRate same as `SendFile` but it can throttle the speed of reading +// and though writing the file to the client. +func (ctx *context) SendFileWithRate(src, destName string, limit float64, burst int) error { + if destName == "" { + destName = filepath.Base(src) + } + + ctx.writer.Header().Set(ContentDispositionHeaderKey, "attachment;filename="+destName) + return ctx.ServeFileWithRate(src, limit, burst) } // +------------------------------------------------------------+ diff --git a/iris.go b/iris.go index a4fa5fda..3c1823f6 100644 --- a/iris.go +++ b/iris.go @@ -591,6 +591,17 @@ const ( ReferrerGoogleAdwords = context.ReferrerGoogleAdwords ) +// Byte unit helpers. +const ( + B = 1 << (10 * iota) + KB + MB + GB + TB + PB + EB +) + // ConfigureHost accepts one or more `host#Configuration`, these configurators functions // can access the host created by `app.Run`, // they're being executed when application is ready to being served to the public. diff --git a/middleware/rate/rate.go b/middleware/rate/rate.go index 40fffb86..ce29ba9f 100644 --- a/middleware/rate/rate.go +++ b/middleware/rate/rate.go @@ -50,10 +50,11 @@ type ( Limiter struct { clientDataFunc func(ctx context.Context) interface{} // fill the Client's Data field. exceedHandler context.Handler // when too many requests. + limit rate.Limit + burstSize int clients map[string]*Client mu sync.RWMutex // mutex for clients. - pool *sync.Pool // object pool for clients. } Client struct { @@ -68,14 +69,10 @@ type ( const Inf = math.MaxFloat64 func Limit(limit float64, burst int, options ...Option) context.Handler { - rateLimit := rate.Limit(limit) - l := &Limiter{ - clients: make(map[string]*Client), - pool: &sync.Pool{New: func() interface{} { - return &Client{limiter: rate.NewLimiter(rateLimit, burst)} - }}, - + clients: make(map[string]*Client), + limit: rate.Limit(limit), + burstSize: burst, exceedHandler: func(ctx context.Context) { ctx.StopWithStatus(429) // Too Many Requests. }, @@ -88,21 +85,10 @@ func Limit(limit float64, burst int, options ...Option) context.Handler { return l.serveHTTP } -func (l *Limiter) acquire() *Client { - return l.pool.Get().(*Client) -} - -func (l *Limiter) release(client *Client) { - client.IP = "" - client.Data = nil - l.pool.Put(client) -} - func (l *Limiter) Purge(condition func(*Client) bool) { l.mu.Lock() for ip, client := range l.clients { if condition(client) { - l.release(client) delete(l.clients, ip) } } @@ -116,12 +102,15 @@ func (l *Limiter) serveHTTP(ctx context.Context) { l.mu.RUnlock() if !ok { - client = l.acquire() - client.IP = ip + client = &Client{ + limiter: rate.NewLimiter(l.limit, l.burstSize), + IP: ip, + } if l.clientDataFunc != nil { client.Data = l.clientDataFunc(ctx) } + // if l.store(ctx, client) { // ^ no, let's keep it simple. l.mu.Lock()