Merge branch 'develop' | v0.5 release
This commit is contained in:
commit
e2fa89777e
56
AUTHORS.md
56
AUTHORS.md
@ -9,6 +9,10 @@ List of all the awesome people working to make Gin the best Web Framework in Go.
|
|||||||
|
|
||||||
People and companies, who have contributed, in alphabetical order.
|
People and companies, who have contributed, in alphabetical order.
|
||||||
|
|
||||||
|
**@858806258 (杰哥)**
|
||||||
|
- Fix typo in example
|
||||||
|
|
||||||
|
|
||||||
**@achedeuzot (Klemen Sever)**
|
**@achedeuzot (Klemen Sever)**
|
||||||
- Fix newline debug printing
|
- Fix newline debug printing
|
||||||
|
|
||||||
@ -21,6 +25,10 @@ People and companies, who have contributed, in alphabetical order.
|
|||||||
- Typos in README
|
- Typos in README
|
||||||
|
|
||||||
|
|
||||||
|
**@alexanderdidenko (Aleksandr Didenko)**
|
||||||
|
- Add support multipart/form-data
|
||||||
|
|
||||||
|
|
||||||
**@alexandernyquist (Alexander Nyquist)**
|
**@alexandernyquist (Alexander Nyquist)**
|
||||||
- Using template.Must to fix multiple return issue
|
- Using template.Must to fix multiple return issue
|
||||||
- ★ Added support for OPTIONS verb
|
- ★ Added support for OPTIONS verb
|
||||||
@ -55,15 +63,39 @@ People and companies, who have contributed, in alphabetical order.
|
|||||||
- Add example about serving static files
|
- Add example about serving static files
|
||||||
|
|
||||||
|
|
||||||
|
**@donileo (Adonis)**
|
||||||
|
- Add NoMethod handler
|
||||||
|
|
||||||
|
|
||||||
**@dutchcoders (DutchCoders)**
|
**@dutchcoders (DutchCoders)**
|
||||||
- ★ Fix security bug that allows client to spoof ip
|
- ★ Fix security bug that allows client to spoof ip
|
||||||
- Fix typo. r.HTMLTemplates -> SetHTMLTemplate
|
- Fix typo. r.HTMLTemplates -> SetHTMLTemplate
|
||||||
|
|
||||||
|
|
||||||
|
**@el3ctro- (Joshua Loper)**
|
||||||
|
- Fix typo in example
|
||||||
|
|
||||||
|
|
||||||
|
**@ethankan (Ethan Kan)**
|
||||||
|
- Unsigned integers in binding
|
||||||
|
|
||||||
|
|
||||||
|
**(Evgeny Persienko)**
|
||||||
|
- Validate sub structures
|
||||||
|
|
||||||
|
|
||||||
|
**@frankbille (Frank Bille)**
|
||||||
|
- Add support for HTTP Realm Auth
|
||||||
|
|
||||||
|
|
||||||
**@fmd (Fareed Dudhia)**
|
**@fmd (Fareed Dudhia)**
|
||||||
- Fix typo. SetHTTPTemplate -> SetHTMLTemplate
|
- Fix typo. SetHTTPTemplate -> SetHTMLTemplate
|
||||||
|
|
||||||
|
|
||||||
|
**@ironiridis (Christopher Harrington)**
|
||||||
|
- Remove old reference
|
||||||
|
|
||||||
|
|
||||||
**@jammie-stackhouse (Jamie Stackhouse)**
|
**@jammie-stackhouse (Jamie Stackhouse)**
|
||||||
- Add more shortcuts for router methods
|
- Add more shortcuts for router methods
|
||||||
|
|
||||||
@ -104,6 +136,10 @@ People and companies, who have contributed, in alphabetical order.
|
|||||||
- ★ work around path.Join removing trailing slashes from routes
|
- ★ work around path.Join removing trailing slashes from routes
|
||||||
|
|
||||||
|
|
||||||
|
**@mattn (Yasuhiro Matsumoto)**
|
||||||
|
- Improve color logger
|
||||||
|
|
||||||
|
|
||||||
**@mdigger (Dmitry Sedykh)**
|
**@mdigger (Dmitry Sedykh)**
|
||||||
- Fixes Form binding when content-type is x-www-form-urlencoded
|
- Fixes Form binding when content-type is x-www-form-urlencoded
|
||||||
- No repeat call c.Writer.Status() in gin.Logger
|
- No repeat call c.Writer.Status() in gin.Logger
|
||||||
@ -138,10 +174,22 @@ People and companies, who have contributed, in alphabetical order.
|
|||||||
- Fix Port usage in README.
|
- Fix Port usage in README.
|
||||||
|
|
||||||
|
|
||||||
|
**@rayrod2030 (Ray Rodriguez)**
|
||||||
|
- Fix typo in example
|
||||||
|
|
||||||
|
|
||||||
|
**@rns**
|
||||||
|
- Fix typo in example
|
||||||
|
|
||||||
|
|
||||||
**@RobAWilkinson (Robert Wilkinson)**
|
**@RobAWilkinson (Robert Wilkinson)**
|
||||||
- Add example of forms and params
|
- Add example of forms and params
|
||||||
|
|
||||||
|
|
||||||
|
**@rogierlommers (Rogier Lommers)**
|
||||||
|
- Add updated static serve example
|
||||||
|
|
||||||
|
|
||||||
**@se77en (Damon Zhao)**
|
**@se77en (Damon Zhao)**
|
||||||
- Improve color logging
|
- Improve color logging
|
||||||
|
|
||||||
@ -166,6 +214,14 @@ People and companies, who have contributed, in alphabetical order.
|
|||||||
- Update httprouter godeps
|
- Update httprouter godeps
|
||||||
|
|
||||||
|
|
||||||
|
**@tebeka (Miki Tebeka)**
|
||||||
|
- Use net/http constants instead of numeric values
|
||||||
|
|
||||||
|
|
||||||
|
**@techjanitor**
|
||||||
|
- Update context.go reserved IPs
|
||||||
|
|
||||||
|
|
||||||
**@yosssi (Keiji Yoshida)**
|
**@yosssi (Keiji Yoshida)**
|
||||||
- Fix link in README
|
- Fix link in README
|
||||||
|
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
#Changelog
|
#Changelog
|
||||||
|
|
||||||
###Gin 0.6 (Mar 7, 2015)
|
###Gin 0.6 (Mar 9, 2015)
|
||||||
|
|
||||||
|
- [ADD] Support multipart/form-data
|
||||||
|
- [ADD] NoMethod handler
|
||||||
|
- [ADD] Validate sub structures
|
||||||
|
- [ADD] Support for HTTP Realm Auth
|
||||||
|
- [FIX] Unsigned integers in binding
|
||||||
|
- [FIX] Improve color logger
|
||||||
|
|
||||||
|
|
||||||
###Gin 0.5 (Feb 7, 2015)
|
###Gin 0.5 (Feb 7, 2015)
|
||||||
|
2
Godeps/Godeps.json
generated
2
Godeps/Godeps.json
generated
@ -4,7 +4,7 @@
|
|||||||
"Deps": [
|
"Deps": [
|
||||||
{
|
{
|
||||||
"ImportPath": "github.com/julienschmidt/httprouter",
|
"ImportPath": "github.com/julienschmidt/httprouter",
|
||||||
"Rev": "00ce1c6a267162792c367acc43b1681a884e1872"
|
"Rev": "b428fda53bb0a764fea9c76c9413512eda291dec"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
132
README.md
132
README.md
@ -1,5 +1,7 @@
|
|||||||
#Gin Web Framework [![GoDoc](https://godoc.org/github.com/gin-gonic/gin?status.svg)](https://godoc.org/github.com/gin-gonic/gin) [![Build Status](https://travis-ci.org/gin-gonic/gin.svg)](https://travis-ci.org/gin-gonic/gin)
|
#Gin Web Framework [![GoDoc](https://godoc.org/github.com/gin-gonic/gin?status.svg)](https://godoc.org/github.com/gin-gonic/gin) [![Build Status](https://travis-ci.org/gin-gonic/gin.svg)](https://travis-ci.org/gin-gonic/gin)
|
||||||
|
|
||||||
|
[![Join the chat at https://gitter.im/gin-gonic/gin](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/gin-gonic/gin?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||||
|
|
||||||
Gin is a web framework written in Golang. It features a martini-like API with much better performance, up to 40 times faster thanks to [httprouter](https://github.com/julienschmidt/httprouter). If you need performance and good productivity, you will love Gin.
|
Gin is a web framework written in Golang. It features a martini-like API with much better performance, up to 40 times faster thanks to [httprouter](https://github.com/julienschmidt/httprouter). If you need performance and good productivity, you will love Gin.
|
||||||
|
|
||||||
![Gin console logger](https://gin-gonic.github.io/gin/other/console.png)
|
![Gin console logger](https://gin-gonic.github.io/gin/other/console.png)
|
||||||
@ -10,21 +12,24 @@ $ cat test.go
|
|||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
router.GET("/", func(c *gin.Context) {
|
router.GET("/", func(c *gin.Context) {
|
||||||
c.String(200, "hello world")
|
c.String(http.StatusOK, "hello world")
|
||||||
})
|
})
|
||||||
router.GET("/ping", func(c *gin.Context) {
|
router.GET("/ping", func(c *gin.Context) {
|
||||||
c.String(200, "pong")
|
c.String(http.StatusOK, "pong")
|
||||||
})
|
})
|
||||||
router.POST("/submit", func(c *gin.Context) {
|
router.POST("/submit", func(c *gin.Context) {
|
||||||
c.String(401, "not authorized")
|
c.String(http.StatusUnauthorized, "not authorized")
|
||||||
})
|
})
|
||||||
router.PUT("/error", func(c *gin.Context) {
|
router.PUT("/error", func(c *gin.Context) {
|
||||||
c.String(500, "and error hapenned :(")
|
c.String(http.StatusInternalServerError, "and error happened :(")
|
||||||
})
|
})
|
||||||
router.Run(":8080")
|
router.Run(":8080")
|
||||||
}
|
}
|
||||||
@ -85,15 +90,18 @@ If you'd like to help out with the project, there's a mailing list and IRC chann
|
|||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
r.GET("/ping", func(c *gin.Context) {
|
r.GET("/ping", func(c *gin.Context) {
|
||||||
c.String(200, "pong")
|
c.String(http.StatusOK, "pong")
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen and server on 0.0.0.0:8080
|
// Listen and serve on 0.0.0.0:8080
|
||||||
r.Run(":8080")
|
r.Run(":8080")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -128,7 +136,7 @@ func main() {
|
|||||||
r.GET("/user/:name", func(c *gin.Context) {
|
r.GET("/user/:name", func(c *gin.Context) {
|
||||||
name := c.Params.ByName("name")
|
name := c.Params.ByName("name")
|
||||||
message := "Hello "+name
|
message := "Hello "+name
|
||||||
c.String(200, message)
|
c.String(http.StatusOK, message)
|
||||||
})
|
})
|
||||||
|
|
||||||
// However, this one will match /user/john/ and also /user/john/send
|
// However, this one will match /user/john/ and also /user/john/send
|
||||||
@ -137,7 +145,7 @@ func main() {
|
|||||||
name := c.Params.ByName("name")
|
name := c.Params.ByName("name")
|
||||||
action := c.Params.ByName("action")
|
action := c.Params.ByName("action")
|
||||||
message := name + " is " + action
|
message := name + " is " + action
|
||||||
c.String(200, message)
|
c.String(http.StatusOK, message)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen and server on 0.0.0.0:8080
|
// Listen and server on 0.0.0.0:8080
|
||||||
@ -155,15 +163,56 @@ func main() {
|
|||||||
c.Request.ParseForm()
|
c.Request.ParseForm()
|
||||||
|
|
||||||
firstname := c.Request.Form.Get("firstname")
|
firstname := c.Request.Form.Get("firstname")
|
||||||
lastname := c.Request.Form.get("lastname")
|
lastname := c.Request.Form.Get("lastname")
|
||||||
|
|
||||||
message := "Hello "+ firstname + lastname
|
message := "Hello "+ firstname + lastname
|
||||||
c.String(200, message)
|
c.String(http.StatusOK, message)
|
||||||
})
|
})
|
||||||
r.Run(":8080")
|
r.Run(":8080")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
###Multipart Form
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginForm struct {
|
||||||
|
User string `form:"user" binding:"required"`
|
||||||
|
Password string `form:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
r.POST("/login", func(c *gin.Context) {
|
||||||
|
|
||||||
|
var form LoginForm
|
||||||
|
c.BindWith(&form, binding.MultipartForm)
|
||||||
|
|
||||||
|
if form.User == "user" && form.Password == "password" {
|
||||||
|
c.JSON(200, gin.H{"status": "you are logged in"})
|
||||||
|
} else {
|
||||||
|
c.JSON(401, gin.H{"status": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Run(":8080")
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Test it with:
|
||||||
|
```bash
|
||||||
|
$ curl -v --form user=user --form password=password http://localhost:8080/login
|
||||||
|
```
|
||||||
|
|
||||||
#### Grouping routes
|
#### Grouping routes
|
||||||
```go
|
```go
|
||||||
func main() {
|
func main() {
|
||||||
@ -272,9 +321,9 @@ func main() {
|
|||||||
|
|
||||||
c.Bind(&json) // This will infer what binder to use depending on the content-type header.
|
c.Bind(&json) // This will infer what binder to use depending on the content-type header.
|
||||||
if json.User == "manu" && json.Password == "123" {
|
if json.User == "manu" && json.Password == "123" {
|
||||||
c.JSON(200, gin.H{"status": "you are logged in"})
|
c.JSON(http.StatusOK, gin.H{"status": "you are logged in"})
|
||||||
} else {
|
} else {
|
||||||
c.JSON(401, gin.H{"status": "unauthorized"})
|
c.JSON(http.StatusUnauthorized, gin.H{"status": "unauthorized"})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -284,9 +333,9 @@ func main() {
|
|||||||
|
|
||||||
c.BindWith(&form, binding.Form) // You can also specify which binder to use. We support binding.Form, binding.JSON and binding.XML.
|
c.BindWith(&form, binding.Form) // You can also specify which binder to use. We support binding.Form, binding.JSON and binding.XML.
|
||||||
if form.User == "manu" && form.Password == "123" {
|
if form.User == "manu" && form.Password == "123" {
|
||||||
c.JSON(200, gin.H{"status": "you are logged in"})
|
c.JSON(http.StatusOK, gin.H{"status": "you are logged in"})
|
||||||
} else {
|
} else {
|
||||||
c.JSON(401, gin.H{"status": "unauthorized"})
|
c.JSON(http.StatusUnauthorized, gin.H{"status": "unauthorized"})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -303,7 +352,7 @@ func main() {
|
|||||||
|
|
||||||
// gin.H is a shortcut for map[string]interface{}
|
// gin.H is a shortcut for map[string]interface{}
|
||||||
r.GET("/someJSON", func(c *gin.Context) {
|
r.GET("/someJSON", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"message": "hey", "status": 200})
|
c.JSON(http.StatusOK, gin.H{"message": "hey", "status": http.StatusOK})
|
||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/moreJSON", func(c *gin.Context) {
|
r.GET("/moreJSON", func(c *gin.Context) {
|
||||||
@ -318,11 +367,11 @@ func main() {
|
|||||||
msg.Number = 123
|
msg.Number = 123
|
||||||
// Note that msg.Name becomes "user" in the JSON
|
// Note that msg.Name becomes "user" in the JSON
|
||||||
// Will output : {"user": "Lena", "Message": "hey", "Number": 123}
|
// Will output : {"user": "Lena", "Message": "hey", "Number": 123}
|
||||||
c.JSON(200, msg)
|
c.JSON(http.StatusOK, msg)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/someXML", func(c *gin.Context) {
|
r.GET("/someXML", func(c *gin.Context) {
|
||||||
c.XML(200, gin.H{"message": "hey", "status": 200})
|
c.XML(http.StatusOK, gin.H{"message": "hey", "status": http.StatusOK})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen and server on 0.0.0.0:8080
|
// Listen and server on 0.0.0.0:8080
|
||||||
@ -331,7 +380,6 @@ func main() {
|
|||||||
```
|
```
|
||||||
|
|
||||||
####Serving static files
|
####Serving static files
|
||||||
|
|
||||||
Use Engine.ServeFiles(path string, root http.FileSystem):
|
Use Engine.ServeFiles(path string, root http.FileSystem):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@ -344,6 +392,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use the following example to serve static files at top level route of your domain. Files are being served from directory ./html.
|
||||||
|
|
||||||
|
```
|
||||||
|
r := gin.Default()
|
||||||
|
r.Use(static.Serve("/", static.LocalFile("html", false)))
|
||||||
|
```
|
||||||
|
|
||||||
Note: this will use `httpNotFound` instead of the Router's `NotFound` handler.
|
Note: this will use `httpNotFound` instead of the Router's `NotFound` handler.
|
||||||
|
|
||||||
####HTML rendering
|
####HTML rendering
|
||||||
@ -356,7 +411,7 @@ func main() {
|
|||||||
r.LoadHTMLGlob("templates/*")
|
r.LoadHTMLGlob("templates/*")
|
||||||
r.GET("/index", func(c *gin.Context) {
|
r.GET("/index", func(c *gin.Context) {
|
||||||
obj := gin.H{"title": "Main website"}
|
obj := gin.H{"title": "Main website"}
|
||||||
c.HTML(200, "index.tmpl", obj)
|
c.HTML(http.StatusOK, "index.tmpl", obj)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen and server on 0.0.0.0:8080
|
// Listen and server on 0.0.0.0:8080
|
||||||
@ -384,13 +439,40 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#####Using layout files with templates
|
||||||
|
```go
|
||||||
|
var baseTemplate = "main.tmpl"
|
||||||
|
|
||||||
|
r.GET("/", func(c *gin.Context) {
|
||||||
|
r.SetHTMLTemplate(template.Must(template.ParseFiles(baseTemplate, "whatever.tmpl")))
|
||||||
|
c.HTML(200, "base", data)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
main.tmpl
|
||||||
|
```html
|
||||||
|
{{define "base"}}
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
{{template "content" .}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
```
|
||||||
|
whatever.tmpl
|
||||||
|
```html
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Hello World!</h1>
|
||||||
|
{{end}}
|
||||||
|
```
|
||||||
|
|
||||||
#### Redirects
|
#### Redirects
|
||||||
|
|
||||||
Issuing a HTTP redirect is easy:
|
Issuing a HTTP redirect is easy:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
r.GET("/test", func(c *gin.Context) {
|
r.GET("/test", func(c *gin.Context) {
|
||||||
c.Redirect(301, "http://www.google.com/")
|
c.Redirect(http.StatusMovedPermanently, "http://www.google.com/")
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
Both internal and external locations are supported.
|
Both internal and external locations are supported.
|
||||||
@ -438,7 +520,7 @@ func main() {
|
|||||||
|
|
||||||
#### Using BasicAuth() middleware
|
#### Using BasicAuth() middleware
|
||||||
```go
|
```go
|
||||||
// similate some private data
|
// simulate some private data
|
||||||
var secrets = gin.H{
|
var secrets = gin.H{
|
||||||
"foo": gin.H{"email": "foo@bar.com", "phone": "123433"},
|
"foo": gin.H{"email": "foo@bar.com", "phone": "123433"},
|
||||||
"austin": gin.H{"email": "austin@example.com", "phone": "666"},
|
"austin": gin.H{"email": "austin@example.com", "phone": "666"},
|
||||||
@ -463,9 +545,9 @@ func main() {
|
|||||||
// get user, it was setted by the BasicAuth middleware
|
// get user, it was setted by the BasicAuth middleware
|
||||||
user := c.MustGet(gin.AuthUserKey).(string)
|
user := c.MustGet(gin.AuthUserKey).(string)
|
||||||
if secret, ok := secrets[user]; ok {
|
if secret, ok := secrets[user]; ok {
|
||||||
c.JSON(200, gin.H{"user": user, "secret": secret})
|
c.JSON(http.StatusOK, gin.H{"user": user, "secret": secret})
|
||||||
} else {
|
} else {
|
||||||
c.JSON(200, gin.H{"user": user, "secret": "NO SECRET :("})
|
c.JSON(http.StatusOK, gin.H{"user": user, "secret": "NO SECRET :("})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
19
auth.go
19
auth.go
@ -8,6 +8,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -28,9 +29,10 @@ func (a authPairs) Len() int { return len(a) }
|
|||||||
func (a authPairs) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
func (a authPairs) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||||
func (a authPairs) Less(i, j int) bool { return a[i].Value < a[j].Value }
|
func (a authPairs) Less(i, j int) bool { return a[i].Value < a[j].Value }
|
||||||
|
|
||||||
// Implements a basic Basic HTTP Authorization. It takes as argument a map[string]string where
|
// Implements a basic Basic HTTP Authorization. It takes as arguments a map[string]string where
|
||||||
// the key is the user name and the value is the password.
|
// the key is the user name and the value is the password, as well as the name of the Realm
|
||||||
func BasicAuth(accounts Accounts) HandlerFunc {
|
// (see http://tools.ietf.org/html/rfc2617#section-1.2)
|
||||||
|
func BasicAuthForRealm(accounts Accounts, realm string) HandlerFunc {
|
||||||
pairs, err := processAccounts(accounts)
|
pairs, err := processAccounts(accounts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
@ -40,7 +42,10 @@ func BasicAuth(accounts Accounts) HandlerFunc {
|
|||||||
user, ok := searchCredential(pairs, c.Request.Header.Get("Authorization"))
|
user, ok := searchCredential(pairs, c.Request.Header.Get("Authorization"))
|
||||||
if !ok {
|
if !ok {
|
||||||
// Credentials doesn't match, we return 401 Unauthorized and abort request.
|
// Credentials doesn't match, we return 401 Unauthorized and abort request.
|
||||||
c.Writer.Header().Set("WWW-Authenticate", "Basic realm=\"Authorization Required\"")
|
if realm == "" {
|
||||||
|
realm = "Authorization Required"
|
||||||
|
}
|
||||||
|
c.Writer.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=\"%s\"", realm))
|
||||||
c.Fail(401, errors.New("Unauthorized"))
|
c.Fail(401, errors.New("Unauthorized"))
|
||||||
} else {
|
} else {
|
||||||
// user is allowed, set UserId to key "user" in this context, the userId can be read later using
|
// user is allowed, set UserId to key "user" in this context, the userId can be read later using
|
||||||
@ -50,6 +55,12 @@ func BasicAuth(accounts Accounts) HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Implements a basic Basic HTTP Authorization. It takes as argument a map[string]string where
|
||||||
|
// the key is the user name and the value is the password.
|
||||||
|
func BasicAuth(accounts Accounts) HandlerFunc {
|
||||||
|
return BasicAuthForRealm(accounts, "")
|
||||||
|
}
|
||||||
|
|
||||||
func processAccounts(accounts Accounts) (authPairs, error) {
|
func processAccounts(accounts Accounts) (authPairs, error) {
|
||||||
if len(accounts) == 0 {
|
if len(accounts) == 0 {
|
||||||
return nil, errors.New("Empty list of authorized credentials")
|
return nil, errors.New("Empty list of authorized credentials")
|
||||||
|
24
auth_test.go
24
auth_test.go
@ -59,3 +59,27 @@ func TestBasicAuth401(t *testing.T) {
|
|||||||
t.Errorf("WWW-Authenticate header is incorrect: %s", w.HeaderMap.Get("Content-Type"))
|
t.Errorf("WWW-Authenticate header is incorrect: %s", w.HeaderMap.Get("Content-Type"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBasicAuth401WithCustomRealm(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/login", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r := New()
|
||||||
|
accounts := Accounts{"foo": "bar"}
|
||||||
|
r.Use(BasicAuthForRealm(accounts, "My Custom Realm"))
|
||||||
|
|
||||||
|
r.GET("/login", func(c *Context) {
|
||||||
|
c.String(200, "autorized")
|
||||||
|
})
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:password")))
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 401 {
|
||||||
|
t.Errorf("Response code should be Not autorized, was: %s", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.HeaderMap.Get("WWW-Authenticate") != "Basic realm=\"My Custom Realm\"" {
|
||||||
|
t.Errorf("WWW-Authenticate header is incorrect: %s", w.HeaderMap.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -25,14 +25,20 @@ type (
|
|||||||
// XML binding
|
// XML binding
|
||||||
xmlBinding struct{}
|
xmlBinding struct{}
|
||||||
|
|
||||||
// // form binding
|
// form binding
|
||||||
formBinding struct{}
|
formBinding struct{}
|
||||||
|
|
||||||
|
// multipart form binding
|
||||||
|
multipartFormBinding struct{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const MAX_MEMORY = 1 * 1024 * 1024
|
||||||
|
|
||||||
var (
|
var (
|
||||||
JSON = jsonBinding{}
|
JSON = jsonBinding{}
|
||||||
XML = xmlBinding{}
|
XML = xmlBinding{}
|
||||||
Form = formBinding{} // todo
|
Form = formBinding{} // todo
|
||||||
|
MultipartForm = multipartFormBinding{}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (_ jsonBinding) Bind(req *http.Request, obj interface{}) error {
|
func (_ jsonBinding) Bind(req *http.Request, obj interface{}) error {
|
||||||
@ -63,6 +69,16 @@ func (_ formBinding) Bind(req *http.Request, obj interface{}) error {
|
|||||||
return Validate(obj)
|
return Validate(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (_ multipartFormBinding) Bind(req *http.Request, obj interface{}) error {
|
||||||
|
if err := req.ParseMultipartForm(MAX_MEMORY); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mapForm(obj, req.Form); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return Validate(obj)
|
||||||
|
}
|
||||||
|
|
||||||
func mapForm(ptr interface{}, form map[string][]string) error {
|
func mapForm(ptr interface{}, form map[string][]string) error {
|
||||||
typ := reflect.TypeOf(ptr).Elem()
|
typ := reflect.TypeOf(ptr).Elem()
|
||||||
formStruct := reflect.ValueOf(ptr).Elem()
|
formStruct := reflect.ValueOf(ptr).Elem()
|
||||||
@ -98,18 +114,54 @@ func mapForm(ptr interface{}, form map[string][]string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setWithProperType(valueKind reflect.Kind, val string, structField reflect.Value) error {
|
func setIntField(val string, bitSize int, structField reflect.Value) error {
|
||||||
switch valueKind {
|
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
||||||
if val == "" {
|
if val == "" {
|
||||||
val = "0"
|
val = "0"
|
||||||
}
|
}
|
||||||
intVal, err := strconv.Atoi(val)
|
|
||||||
if err != nil {
|
intVal, err := strconv.ParseInt(val, 10, bitSize)
|
||||||
return err
|
if err == nil {
|
||||||
} else {
|
structField.SetInt(intVal)
|
||||||
structField.SetInt(int64(intVal))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUintField(val string, bitSize int, structField reflect.Value) error {
|
||||||
|
if val == "" {
|
||||||
|
val = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
uintVal, err := strconv.ParseUint(val, 10, bitSize)
|
||||||
|
if err == nil {
|
||||||
|
structField.SetUint(uintVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWithProperType(valueKind reflect.Kind, val string, structField reflect.Value) error {
|
||||||
|
switch valueKind {
|
||||||
|
case reflect.Int:
|
||||||
|
return setIntField(val, 0, structField)
|
||||||
|
case reflect.Int8:
|
||||||
|
return setIntField(val, 8, structField)
|
||||||
|
case reflect.Int16:
|
||||||
|
return setIntField(val, 16, structField)
|
||||||
|
case reflect.Int32:
|
||||||
|
return setIntField(val, 32, structField)
|
||||||
|
case reflect.Int64:
|
||||||
|
return setIntField(val, 64, structField)
|
||||||
|
case reflect.Uint:
|
||||||
|
return setUintField(val, 0, structField)
|
||||||
|
case reflect.Uint8:
|
||||||
|
return setUintField(val, 8, structField)
|
||||||
|
case reflect.Uint16:
|
||||||
|
return setUintField(val, 16, structField)
|
||||||
|
case reflect.Uint32:
|
||||||
|
return setUintField(val, 32, structField)
|
||||||
|
case reflect.Uint64:
|
||||||
|
return setUintField(val, 64, structField)
|
||||||
case reflect.Bool:
|
case reflect.Bool:
|
||||||
if val == "" {
|
if val == "" {
|
||||||
val = "false"
|
val = "false"
|
||||||
@ -199,6 +251,22 @@ func Validate(obj interface{}, parents ...string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
fieldType := field.Type.Kind()
|
||||||
|
if fieldType == reflect.Struct {
|
||||||
|
if reflect.DeepEqual(zero, fieldValue) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := Validate(fieldValue, field.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if fieldType == reflect.Slice && field.Type.Elem().Kind() == reflect.Struct {
|
||||||
|
err := Validate(fieldValue, field.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case reflect.Slice:
|
case reflect.Slice:
|
||||||
|
@ -230,7 +230,7 @@ func ipInMasks(ip net.IP, masks []interface{}) bool {
|
|||||||
func ForwardedFor(proxies ...interface{}) HandlerFunc {
|
func ForwardedFor(proxies ...interface{}) HandlerFunc {
|
||||||
if len(proxies) == 0 {
|
if len(proxies) == 0 {
|
||||||
// default to local ips
|
// default to local ips
|
||||||
var reservedLocalIps = []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}
|
var reservedLocalIps = []string{"10.0.0.0/8", "127.0.0.1/32", "172.16.0.0/12", "192.168.0.0/16"}
|
||||||
|
|
||||||
proxies = make([]interface{}, len(reservedLocalIps))
|
proxies = make([]interface{}, len(reservedLocalIps))
|
||||||
|
|
||||||
@ -295,6 +295,8 @@ func (c *Context) Bind(obj interface{}) bool {
|
|||||||
switch {
|
switch {
|
||||||
case c.Request.Method == "GET" || ctype == MIMEPOSTForm:
|
case c.Request.Method == "GET" || ctype == MIMEPOSTForm:
|
||||||
b = binding.Form
|
b = binding.Form
|
||||||
|
case ctype == MIMEMultipartPOSTForm:
|
||||||
|
b = binding.MultipartForm
|
||||||
case ctype == MIMEJSON:
|
case ctype == MIMEJSON:
|
||||||
b = binding.JSON
|
b = binding.JSON
|
||||||
case ctype == MIMEXML || ctype == MIMEXML2:
|
case ctype == MIMEXML || ctype == MIMEXML2:
|
||||||
@ -349,6 +351,11 @@ func (c *Context) String(code int, format string, values ...interface{}) {
|
|||||||
c.Render(code, render.Plain, format, values)
|
c.Render(code, render.Plain, format, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Writes the given string into the response body and sets the Content-Type to "text/html" without template.
|
||||||
|
func (c *Context) HTMLString(code int, format string, values ...interface{}) {
|
||||||
|
c.Render(code, render.HTMLPlain, format, values)
|
||||||
|
}
|
||||||
|
|
||||||
// Returns a HTTP redirect to the specific location.
|
// Returns a HTTP redirect to the specific location.
|
||||||
func (c *Context) Redirect(code int, location string) {
|
func (c *Context) Redirect(code int, location string) {
|
||||||
if code >= 300 && code <= 308 {
|
if code >= 300 && code <= 308 {
|
||||||
|
@ -441,6 +441,42 @@ func TestBindingJSONMalformed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBindingForm(t *testing.T) {
|
||||||
|
|
||||||
|
body := bytes.NewBuffer([]byte("foo=bar&num=123&unum=1234567890"))
|
||||||
|
|
||||||
|
r := New()
|
||||||
|
r.POST("/binding/form", func(c *Context) {
|
||||||
|
var body struct {
|
||||||
|
Foo string `form:"foo"`
|
||||||
|
Num int `form:"num"`
|
||||||
|
Unum uint `form:"unum"`
|
||||||
|
}
|
||||||
|
if c.Bind(&body) {
|
||||||
|
c.JSON(200, H{"foo": body.Foo, "num": body.Num, "unum": body.Unum})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", "/binding/form", body)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Errorf("Response code should be Ok, was: %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "{\"foo\":\"bar\",\"num\":123,\"unum\":1234567890}\n"
|
||||||
|
if w.Body.String() != expected {
|
||||||
|
t.Errorf("Response should be %s, was %s", expected, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.HeaderMap.Get("Content-Type") != "application/json; charset=utf-8" {
|
||||||
|
t.Errorf("Content-Type should be application/json, was %s", w.HeaderMap.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClientIP(t *testing.T) {
|
func TestClientIP(t *testing.T) {
|
||||||
r := New()
|
r := New()
|
||||||
|
|
||||||
|
36
gin.go
36
gin.go
@ -21,6 +21,7 @@ const (
|
|||||||
MIMEXML2 = "text/xml"
|
MIMEXML2 = "text/xml"
|
||||||
MIMEPlain = "text/plain"
|
MIMEPlain = "text/plain"
|
||||||
MIMEPOSTForm = "application/x-www-form-urlencoded"
|
MIMEPOSTForm = "application/x-www-form-urlencoded"
|
||||||
|
MIMEMultipartPOSTForm = "multipart/form-data"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@ -31,9 +32,11 @@ type (
|
|||||||
*RouterGroup
|
*RouterGroup
|
||||||
HTMLRender render.Render
|
HTMLRender render.Render
|
||||||
Default404Body []byte
|
Default404Body []byte
|
||||||
|
Default405Body []byte
|
||||||
pool sync.Pool
|
pool sync.Pool
|
||||||
allNoRoute []HandlerFunc
|
allNoRouteNoMethod []HandlerFunc
|
||||||
noRoute []HandlerFunc
|
noRoute []HandlerFunc
|
||||||
|
noMethod []HandlerFunc
|
||||||
router *httprouter.Router
|
router *httprouter.Router
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -49,7 +52,9 @@ func New() *Engine {
|
|||||||
}
|
}
|
||||||
engine.router = httprouter.New()
|
engine.router = httprouter.New()
|
||||||
engine.Default404Body = []byte("404 page not found")
|
engine.Default404Body = []byte("404 page not found")
|
||||||
|
engine.Default405Body = []byte("405 method not allowed")
|
||||||
engine.router.NotFound = engine.handle404
|
engine.router.NotFound = engine.handle404
|
||||||
|
engine.router.MethodNotAllowed = engine.handle405
|
||||||
engine.pool.New = func() interface{} {
|
engine.pool.New = func() interface{} {
|
||||||
c := &Context{Engine: engine}
|
c := &Context{Engine: engine}
|
||||||
c.Writer = &c.writermem
|
c.Writer = &c.writermem
|
||||||
@ -97,17 +102,27 @@ func (engine *Engine) NoRoute(handlers ...HandlerFunc) {
|
|||||||
engine.rebuild404Handlers()
|
engine.rebuild404Handlers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (engine *Engine) NoMethod(handlers ...HandlerFunc) {
|
||||||
|
engine.noMethod = handlers
|
||||||
|
engine.rebuild405Handlers()
|
||||||
|
}
|
||||||
|
|
||||||
func (engine *Engine) Use(middlewares ...HandlerFunc) {
|
func (engine *Engine) Use(middlewares ...HandlerFunc) {
|
||||||
engine.RouterGroup.Use(middlewares...)
|
engine.RouterGroup.Use(middlewares...)
|
||||||
engine.rebuild404Handlers()
|
engine.rebuild404Handlers()
|
||||||
|
engine.rebuild405Handlers()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (engine *Engine) rebuild404Handlers() {
|
func (engine *Engine) rebuild404Handlers() {
|
||||||
engine.allNoRoute = engine.combineHandlers(engine.noRoute)
|
engine.allNoRouteNoMethod = engine.combineHandlers(engine.noRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (engine *Engine) rebuild405Handlers() {
|
||||||
|
engine.allNoRouteNoMethod = engine.combineHandlers(engine.noMethod)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (engine *Engine) handle404(w http.ResponseWriter, req *http.Request) {
|
func (engine *Engine) handle404(w http.ResponseWriter, req *http.Request) {
|
||||||
c := engine.createContext(w, req, nil, engine.allNoRoute)
|
c := engine.createContext(w, req, nil, engine.allNoRouteNoMethod)
|
||||||
// set 404 by default, useful for logging
|
// set 404 by default, useful for logging
|
||||||
c.Writer.WriteHeader(404)
|
c.Writer.WriteHeader(404)
|
||||||
c.Next()
|
c.Next()
|
||||||
@ -121,6 +136,21 @@ func (engine *Engine) handle404(w http.ResponseWriter, req *http.Request) {
|
|||||||
engine.reuseContext(c)
|
engine.reuseContext(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (engine *Engine) handle405(w http.ResponseWriter, req *http.Request) {
|
||||||
|
c := engine.createContext(w, req, nil, engine.allNoRouteNoMethod)
|
||||||
|
// set 405 by default, useful for logging
|
||||||
|
c.Writer.WriteHeader(405)
|
||||||
|
c.Next()
|
||||||
|
if !c.Writer.Written() {
|
||||||
|
if c.Writer.Status() == 405 {
|
||||||
|
c.Data(-1, MIMEPlain, engine.Default405Body)
|
||||||
|
} else {
|
||||||
|
c.Writer.WriteHeaderNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
engine.reuseContext(c)
|
||||||
|
}
|
||||||
|
|
||||||
// ServeHTTP makes the router implement the http.Handler interface.
|
// ServeHTTP makes the router implement the http.Handler interface.
|
||||||
func (engine *Engine) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
func (engine *Engine) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||||
engine.router.ServeHTTP(writer, request)
|
engine.router.ServeHTTP(writer, request)
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
package gin
|
package gin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/mattn/go-colorable"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ func ErrorLoggerT(typ uint32) HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Logger() HandlerFunc {
|
func Logger() HandlerFunc {
|
||||||
stdlogger := log.New(os.Stdout, "", 0)
|
stdlogger := log.New(colorable.NewColorableStdout(), "", 0)
|
||||||
//errlogger := log.New(os.Stderr, "", 0)
|
//errlogger := log.New(os.Stderr, "", 0)
|
||||||
|
|
||||||
return func(c *Context) {
|
return func(c *Context) {
|
||||||
|
@ -82,7 +82,7 @@ func function(pc uintptr) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recovery returns a middleware that recovers from any panics and writes a 500 if there was one.
|
// Recovery returns a middleware that recovers from any panics and writes a 500 if there was one.
|
||||||
// While Martini is in development mode, Recovery will also output the panic as HTML.
|
// While Gin is in development mode, Recovery will also output the panic as HTML.
|
||||||
func Recovery() HandlerFunc {
|
func Recovery() HandlerFunc {
|
||||||
return func(c *Context) {
|
return func(c *Context) {
|
||||||
defer func() {
|
defer func() {
|
||||||
|
@ -26,6 +26,9 @@ type (
|
|||||||
// Plain text
|
// Plain text
|
||||||
plainRender struct{}
|
plainRender struct{}
|
||||||
|
|
||||||
|
// HTML Plain text
|
||||||
|
htmlPlainRender struct{}
|
||||||
|
|
||||||
// Redirects
|
// Redirects
|
||||||
redirectRender struct{}
|
redirectRender struct{}
|
||||||
|
|
||||||
@ -45,6 +48,7 @@ var (
|
|||||||
JSON = jsonRender{}
|
JSON = jsonRender{}
|
||||||
XML = xmlRender{}
|
XML = xmlRender{}
|
||||||
Plain = plainRender{}
|
Plain = plainRender{}
|
||||||
|
HTMLPlain = htmlPlainRender{}
|
||||||
Redirect = redirectRender{}
|
Redirect = redirectRender{}
|
||||||
HTMLDebug = &htmlDebugRender{}
|
HTMLDebug = &htmlDebugRender{}
|
||||||
)
|
)
|
||||||
@ -85,6 +89,19 @@ func (_ plainRender) Render(w http.ResponseWriter, code int, data ...interface{}
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (_ htmlPlainRender) Render(w http.ResponseWriter, code int, data ...interface{}) error {
|
||||||
|
writeHeader(w, code, "text/html")
|
||||||
|
format := data[0].(string)
|
||||||
|
args := data[1].([]interface{})
|
||||||
|
var err error
|
||||||
|
if len(args) > 0 {
|
||||||
|
_, err = w.Write([]byte(fmt.Sprintf(format, args...)))
|
||||||
|
} else {
|
||||||
|
_, err = w.Write([]byte(format))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *htmlDebugRender) AddGlob(pattern string) {
|
func (r *htmlDebugRender) AddGlob(pattern string) {
|
||||||
r.globs = append(r.globs, pattern)
|
r.globs = append(r.globs, pattern)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user