mirror of
https://github.com/kataras/iris.git
synced 2025-02-02 15:30:36 +01:00
context transactions removed and make Context.Domain customizable as requested
This commit is contained in:
parent
c6911851f1
commit
d8af2a1e14
|
@ -28,6 +28,8 @@ The codebase for Dependency Injection, Internationalization and localization and
|
||||||
|
|
||||||
## Fixes and Improvements
|
## Fixes and Improvements
|
||||||
|
|
||||||
|
- Make `Context.Domain()` customizable by letting developers to set the custom `Context.GetDomain` package-level function.
|
||||||
|
- Remove Request Context-based Transaction feature as its usage can be replaced with just the Iris Context (as of go1.7+) and better [project](_examples/project) structure.
|
||||||
- Fix [#1882](https://github.com/kataras/iris/issues/1882)
|
- Fix [#1882](https://github.com/kataras/iris/issues/1882)
|
||||||
- Fix [#1877](https://github.com/kataras/iris/issues/1877)
|
- Fix [#1877](https://github.com/kataras/iris/issues/1877)
|
||||||
- Fix [#1876](https://github.com/kataras/iris/issues/1876)
|
- Fix [#1876](https://github.com/kataras/iris/issues/1876)
|
||||||
|
|
|
@ -200,7 +200,6 @@
|
||||||
* [Protocol Buffers](response-writer/protobuf/main.go)
|
* [Protocol Buffers](response-writer/protobuf/main.go)
|
||||||
* [HTTP/2 Server Push](response-writer/http2push/main.go)
|
* [HTTP/2 Server Push](response-writer/http2push/main.go)
|
||||||
* [Stream Writer](response-writer/stream-writer/main.go)
|
* [Stream Writer](response-writer/stream-writer/main.go)
|
||||||
* [Transactions](response-writer/transactions/main.go)
|
|
||||||
* [Server-Sent Events](response-writer/sse/main.go)
|
* [Server-Sent Events](response-writer/sse/main.go)
|
||||||
* [SSE 3rd-party (r3labs/sse)](response-writer/sse-third-party/main.go)
|
* [SSE 3rd-party (r3labs/sse)](response-writer/sse-third-party/main.go)
|
||||||
* [SSE 3rd-party (alexandrevicenzi/go-sse)](response-writer/sse-third-party-2/main.go)
|
* [SSE 3rd-party (alexandrevicenzi/go-sse)](response-writer/sse-third-party-2/main.go)
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/kataras/iris/v12"
|
|
||||||
"github.com/kataras/iris/v12/context"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
app := iris.New()
|
|
||||||
|
|
||||||
// subdomains works with all available routers, like other features too.
|
|
||||||
|
|
||||||
app.Get("/", func(ctx iris.Context) {
|
|
||||||
ctx.BeginTransaction(func(t *context.Transaction) {
|
|
||||||
// OPTIONAl STEP: , if true then the next transictions will not be executed if this transiction fails
|
|
||||||
// t.SetScope(context.RequestTransactionScope)
|
|
||||||
|
|
||||||
// OPTIONAL STEP:
|
|
||||||
// create a new custom type of error here to keep track of the status code and reason message
|
|
||||||
err := context.NewTransactionErrResult()
|
|
||||||
|
|
||||||
// we should use t.Context if we want to rollback on any errors lives inside this function clojure.
|
|
||||||
t.Context().Text("Blablabla this should not be sent to the client because we will fill the err with a message and status")
|
|
||||||
|
|
||||||
// virtualize a fake error here, for the sake of the example
|
|
||||||
fail := true
|
|
||||||
if fail {
|
|
||||||
err.StatusCode = iris.StatusInternalServerError
|
|
||||||
// NOTE: if empty reason then the default or the custom http error will be fired (like ctx.FireStatusCode)
|
|
||||||
err.Reason = "Error: Virtual failure!!"
|
|
||||||
}
|
|
||||||
|
|
||||||
// OPTIONAl STEP:
|
|
||||||
// but useful if we want to post back an error message to the client if the transaction failed.
|
|
||||||
// if the reason is empty then the transaction completed successfully,
|
|
||||||
// otherwise we rollback the whole response writer's body,
|
|
||||||
// headers and cookies, status code and everything lives inside this transaction
|
|
||||||
t.Complete(err)
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx.BeginTransaction(func(t *context.Transaction) {
|
|
||||||
t.Context().HTML("<h1>This will sent at all cases because it lives on different transaction and it doesn't fails</h1>")
|
|
||||||
// * if we don't have any 'throw error' logic then no need of scope.Complete()
|
|
||||||
})
|
|
||||||
|
|
||||||
// OPTIONALLY, depends on the usage:
|
|
||||||
// at any case, what ever happens inside the context's transactions send this to the client
|
|
||||||
ctx.HTML("<h1>Let's add a second html message to the response, " +
|
|
||||||
"if the transaction was failed and it was request scoped then this message would " +
|
|
||||||
"not been shown. But it has a transient scope(default) so, it is visible as expected!</h1>")
|
|
||||||
})
|
|
||||||
|
|
||||||
app.Listen(":8080")
|
|
||||||
}
|
|
|
@ -1001,7 +1001,8 @@ func (ctx *Context) Host() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDomain resolves and returns the server's domain.
|
// GetDomain resolves and returns the server's domain.
|
||||||
func GetDomain(hostport string) string {
|
// To customize its behavior, developers can modify this package-level function at initialization.
|
||||||
|
var GetDomain = func(hostport string) string {
|
||||||
host := hostport
|
host := hostport
|
||||||
if tmp, _, err := net.SplitHostPort(hostport); err == nil {
|
if tmp, _, err := net.SplitHostPort(hostport); err == nil {
|
||||||
host = tmp
|
host = tmp
|
||||||
|
@ -1459,7 +1460,7 @@ func (ctx *Context) GetContentLength() int64 {
|
||||||
// StatusCode sets the status code header to the response.
|
// StatusCode sets the status code header to the response.
|
||||||
// Look .GetStatusCode & .FireStatusCode too.
|
// Look .GetStatusCode & .FireStatusCode too.
|
||||||
//
|
//
|
||||||
// Remember, the last one before .Write matters except recorder and transactions.
|
// Note that you must set status code before write response body (except when recorder is used).
|
||||||
func (ctx *Context) StatusCode(statusCode int) {
|
func (ctx *Context) StatusCode(statusCode int) {
|
||||||
ctx.writer.WriteHeader(statusCode)
|
ctx.writer.WriteHeader(statusCode)
|
||||||
}
|
}
|
||||||
|
@ -5656,7 +5657,7 @@ func (ctx *Context) MaxAge() int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// +------------------------------------------------------------+
|
// +------------------------------------------------------------+
|
||||||
// | Advanced: Response Recorder and Transactions |
|
// | Advanced: Response Recorder |
|
||||||
// +------------------------------------------------------------+
|
// +------------------------------------------------------------+
|
||||||
|
|
||||||
// Record transforms the context's basic and direct responseWriter to a *ResponseRecorder
|
// Record transforms the context's basic and direct responseWriter to a *ResponseRecorder
|
||||||
|
@ -5691,72 +5692,6 @@ func (ctx *Context) IsRecording() (*ResponseRecorder, bool) {
|
||||||
return rr, ok
|
return rr, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrTransactionInterrupt can be used to manually force-complete a Context's transaction
|
|
||||||
// and log(warn) the wrapped error's message.
|
|
||||||
// Usage: `... return fmt.Errorf("my custom error message: %w", context.ErrTransactionInterrupt)`.
|
|
||||||
var ErrTransactionInterrupt = errors.New("transaction interrupted")
|
|
||||||
|
|
||||||
// BeginTransaction starts a scoped transaction.
|
|
||||||
//
|
|
||||||
// Can't say a lot here because it will take more than 200 lines to write about.
|
|
||||||
// You can search third-party articles or books on how Business Transaction works (it's quite simple, especially here).
|
|
||||||
//
|
|
||||||
// Note that this is unique and new
|
|
||||||
// (=I haver never seen any other examples or code in Golang on this subject, so far, as with the most of iris features...)
|
|
||||||
// it's not covers all paths,
|
|
||||||
// such as databases, this should be managed by the libraries you use to make your database connection,
|
|
||||||
// this transaction scope is only for context's response.
|
|
||||||
// Transactions have their own middleware ecosystem also.
|
|
||||||
//
|
|
||||||
// See https://github.com/kataras/iris/tree/master/_examples/ for more
|
|
||||||
func (ctx *Context) BeginTransaction(pipe func(t *Transaction)) {
|
|
||||||
// do NOT begin a transaction when the previous transaction has been failed
|
|
||||||
// and it was requested scoped or SkipTransactions called manually.
|
|
||||||
if ctx.TransactionsSkipped() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// start recording in order to be able to control the full response writer
|
|
||||||
ctx.Record()
|
|
||||||
|
|
||||||
t := newTransaction(ctx) // it calls this *context, so the overriding with a new pool's New of context.Context wil not work here.
|
|
||||||
defer func() {
|
|
||||||
if err := recover(); err != nil {
|
|
||||||
ctx.app.Logger().Warn(fmt.Errorf("recovery from panic: %w", ErrTransactionInterrupt))
|
|
||||||
// complete (again or not , doesn't matters) the scope without loud
|
|
||||||
t.Complete(nil)
|
|
||||||
// we continue as normal, no need to return here*
|
|
||||||
}
|
|
||||||
|
|
||||||
// write the temp contents to the original writer
|
|
||||||
t.Context().ResponseWriter().CopyTo(ctx.writer)
|
|
||||||
// give back to the transaction the original writer (SetBeforeFlush works this way and only this way)
|
|
||||||
// this is tricky but nessecery if we want ctx.FireStatusCode to work inside transactions
|
|
||||||
t.Context().ResetResponseWriter(ctx.writer)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// run the worker with its context clone inside.
|
|
||||||
pipe(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// skipTransactionsContextKey set this to any value to stop executing next transactions
|
|
||||||
// it's a context-key in order to be used from anywhere, set it by calling the SkipTransactions()
|
|
||||||
const skipTransactionsContextKey = "iris.transactions.skip"
|
|
||||||
|
|
||||||
// SkipTransactions if called then skip the rest of the transactions
|
|
||||||
// or all of them if called before the first transaction
|
|
||||||
func (ctx *Context) SkipTransactions() {
|
|
||||||
ctx.values.Set(skipTransactionsContextKey, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TransactionsSkipped returns true if the transactions skipped or canceled at all.
|
|
||||||
func (ctx *Context) TransactionsSkipped() bool {
|
|
||||||
if n, err := ctx.values.GetInt(skipTransactionsContextKey); err == nil && n == 1 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exec calls the framewrok's ServeHTTPC
|
// Exec calls the framewrok's ServeHTTPC
|
||||||
// based on this context but with a changed method and path
|
// based on this context but with a changed method and path
|
||||||
// like it was requested by the user, but it is not.
|
// like it was requested by the user, but it is not.
|
||||||
|
|
|
@ -29,17 +29,14 @@ func releaseResponseRecorder(w *ResponseRecorder) {
|
||||||
rrpool.Put(w)
|
rrpool.Put(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A ResponseRecorder is used mostly by context's transactions
|
// A ResponseRecorder is used mostly for testing
|
||||||
// in order to record and change if needed the body, status code and headers.
|
// in order to record and modify, if necessary, the body and status code and headers.
|
||||||
//
|
//
|
||||||
// Developers are not limited to manually ask to record a response.
|
// See `context.Recorder`` method too.
|
||||||
// To turn on the recorder from a Handler,
|
|
||||||
// rec := context.Recorder()
|
|
||||||
type ResponseRecorder struct {
|
type ResponseRecorder struct {
|
||||||
ResponseWriter
|
ResponseWriter
|
||||||
|
|
||||||
// keep track of the body in order to be
|
// keep track of the body written.
|
||||||
// resetable and useful inside custom transactions
|
|
||||||
chunks []byte
|
chunks []byte
|
||||||
// the saved headers
|
// the saved headers
|
||||||
headers http.Header
|
headers http.Header
|
||||||
|
|
|
@ -1,179 +0,0 @@
|
||||||
package context
|
|
||||||
|
|
||||||
// TransactionErrResult could be named also something like 'MaybeError',
|
|
||||||
// it is useful to send it on transaction.Complete in order to execute a custom error mesasge to the user.
|
|
||||||
//
|
|
||||||
// in simple words it's just a 'traveler message' between the transaction and its scope.
|
|
||||||
// it is totally optional
|
|
||||||
type TransactionErrResult struct {
|
|
||||||
StatusCode int
|
|
||||||
// if reason is empty then the already relative registered (custom or not)
|
|
||||||
// error will be executed if the scope allows that.
|
|
||||||
Reason string
|
|
||||||
ContentType string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error returns the reason given by the user or an empty string
|
|
||||||
func (err TransactionErrResult) Error() string {
|
|
||||||
return err.Reason
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsFailure returns true if this is an actual error
|
|
||||||
func (err TransactionErrResult) IsFailure() bool {
|
|
||||||
return StatusCodeNotSuccessful(err.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTransactionErrResult returns a new transaction result with the given error message,
|
|
||||||
// it can be empty too, but if not then the transaction's scope is decided what to do with that
|
|
||||||
func NewTransactionErrResult() TransactionErrResult {
|
|
||||||
return TransactionErrResult{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TransactionScope is the manager of the transaction's response, can be resseted and skipped
|
|
||||||
// from its parent context or execute an error or skip other transactions
|
|
||||||
type TransactionScope interface {
|
|
||||||
// EndTransaction returns if can continue to the next transactions or not (false)
|
|
||||||
// called after Complete, empty or not empty error
|
|
||||||
EndTransaction(maybeErr TransactionErrResult, ctx *Context) bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// TransactionScopeFunc the transaction's scope signature
|
|
||||||
type TransactionScopeFunc func(maybeErr TransactionErrResult, ctx *Context) bool
|
|
||||||
|
|
||||||
// EndTransaction ends the transaction with a callback to itself, implements the TransactionScope interface
|
|
||||||
func (tsf TransactionScopeFunc) EndTransaction(maybeErr TransactionErrResult, ctx *Context) bool {
|
|
||||||
return tsf(maybeErr, ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// +------------------------------------------------------------+
|
|
||||||
// | Transaction Implementation |
|
|
||||||
// +------------------------------------------------------------+
|
|
||||||
|
|
||||||
// Transaction gives the users the opportunity to code their route handlers cleaner and safier
|
|
||||||
// it receives a scope which is decided when to send an error to the user, recover from panics
|
|
||||||
// stop the execution of the next transactions and so on...
|
|
||||||
//
|
|
||||||
// it's default scope is the TransientTransactionScope which is silently
|
|
||||||
// skips the current transaction's response if transaction.Complete accepts a non-empty error.
|
|
||||||
//
|
|
||||||
// Create and set custom transactions scopes with transaction.SetScope.
|
|
||||||
//
|
|
||||||
// For more information please visit the tests.
|
|
||||||
type Transaction struct {
|
|
||||||
context *Context
|
|
||||||
parent *Context
|
|
||||||
hasError bool
|
|
||||||
scope TransactionScope
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTransaction(from *Context) *Transaction {
|
|
||||||
tempCtx := *from
|
|
||||||
writer := tempCtx.ResponseWriter().Clone()
|
|
||||||
tempCtx.ResetResponseWriter(writer)
|
|
||||||
t := &Transaction{
|
|
||||||
parent: from,
|
|
||||||
context: &tempCtx,
|
|
||||||
scope: TransientTransactionScope,
|
|
||||||
}
|
|
||||||
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Context returns the current context of the transaction.
|
|
||||||
func (t *Transaction) Context() *Context {
|
|
||||||
return t.context
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetScope sets the current transaction's scope
|
|
||||||
// iris.RequestTransactionScope || iris.TransientTransactionScope (default).
|
|
||||||
func (t *Transaction) SetScope(scope TransactionScope) {
|
|
||||||
t.scope = scope
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete completes the transaction
|
|
||||||
// rollback and send an error when the error is not empty.
|
|
||||||
// The next steps depends on its Scope.
|
|
||||||
//
|
|
||||||
// The error can be a type of context.NewTransactionErrResult().
|
|
||||||
func (t *Transaction) Complete(err error) {
|
|
||||||
maybeErr := TransactionErrResult{}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.hasError = true
|
|
||||||
|
|
||||||
statusCode := 400 // bad request
|
|
||||||
reason := err.Error()
|
|
||||||
cType := "text/plain; charset=" + t.context.Application().ConfigurationReadOnly().GetCharset()
|
|
||||||
|
|
||||||
if errWstatus, ok := err.(TransactionErrResult); ok {
|
|
||||||
if errWstatus.StatusCode > 0 {
|
|
||||||
statusCode = errWstatus.StatusCode
|
|
||||||
}
|
|
||||||
|
|
||||||
if errWstatus.Reason != "" {
|
|
||||||
reason = errWstatus.Reason
|
|
||||||
}
|
|
||||||
// get the content type used on this transaction
|
|
||||||
if cTypeH := t.context.GetContentType(); cTypeH != "" {
|
|
||||||
cType = cTypeH
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
maybeErr.StatusCode = statusCode
|
|
||||||
maybeErr.Reason = reason
|
|
||||||
maybeErr.ContentType = cType
|
|
||||||
}
|
|
||||||
// the transaction ends with error or not error, it decides what to do next with its Response
|
|
||||||
// the Response is appended to the parent context an all cases but it checks for empty body,headers and all that,
|
|
||||||
// if they are empty (silent error or not error at all)
|
|
||||||
// then all transaction's actions are skipped as expected
|
|
||||||
canContinue := t.scope.EndTransaction(maybeErr, t.context)
|
|
||||||
if !canContinue {
|
|
||||||
t.parent.SkipTransactions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TransientTransactionScope explanation:
|
|
||||||
//
|
|
||||||
// independent 'silent' scope, if transaction fails (if transaction.IsFailure() == true)
|
|
||||||
// then its response is not written to the real context no error is provided to the user.
|
|
||||||
// useful for the most cases.
|
|
||||||
var TransientTransactionScope = TransactionScopeFunc(func(maybeErr TransactionErrResult, ctx *Context) bool {
|
|
||||||
if maybeErr.IsFailure() {
|
|
||||||
ctx.Recorder().Reset() // this response is skipped because it's empty.
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// RequestTransactionScope explanation:
|
|
||||||
//
|
|
||||||
// if scope fails (if transaction.IsFailure() == true)
|
|
||||||
// then the rest of the context's response (transaction or normal flow)
|
|
||||||
// is not written to the client, and an error status code is written instead.
|
|
||||||
var RequestTransactionScope = TransactionScopeFunc(func(maybeErr TransactionErrResult, ctx *Context) bool {
|
|
||||||
if maybeErr.IsFailure() {
|
|
||||||
|
|
||||||
// we need to register a beforeResponseFlush event here in order
|
|
||||||
// to execute last the FireStatusCode
|
|
||||||
// (which will reset the whole response's body, status code and headers set from normal flow or other transactions too)
|
|
||||||
ctx.ResponseWriter().SetBeforeFlush(func() {
|
|
||||||
// we need to re-take the context's response writer
|
|
||||||
// because inside here the response writer is changed to the original's
|
|
||||||
// look ~context:1306
|
|
||||||
w := ctx.ResponseWriter().(*ResponseRecorder)
|
|
||||||
if maybeErr.Reason != "" {
|
|
||||||
// send the error with the info user provided
|
|
||||||
w.SetBodyString(maybeErr.Reason)
|
|
||||||
w.WriteHeader(maybeErr.StatusCode)
|
|
||||||
ctx.ContentType(maybeErr.ContentType)
|
|
||||||
} else {
|
|
||||||
// else execute the registered user error and skip the next transactions and all normal flow,
|
|
||||||
ctx.StopWithStatus(maybeErr.StatusCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
|
@ -7,15 +7,30 @@ import (
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterOnInterrupt registers a global function to call when CTRL+C/CMD+C pressed or a unix kill command received.
|
// RegisterOnInterrupt registers a global function to call when CTRL+C pressed or a unix kill command received.
|
||||||
func RegisterOnInterrupt(cb func()) {
|
func RegisterOnInterrupt(cb func()) {
|
||||||
|
// var cb func()
|
||||||
|
// switch v := callbackFuncOrFuncReturnsError.(type) {
|
||||||
|
// case func():
|
||||||
|
// cb = v
|
||||||
|
// case func() error:
|
||||||
|
// cb = func() { v() }
|
||||||
|
// default:
|
||||||
|
// panic(fmt.Errorf("unknown type of RegisterOnInterrupt callback: expected func() or func() error but got: %T", v))
|
||||||
|
// }
|
||||||
|
|
||||||
Interrupt.Register(cb)
|
Interrupt.Register(cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interrupt watches the os.Signals for interruption signals
|
// Interrupt watches the os.Signals for interruption signals
|
||||||
// and fires the callbacks when those happens.
|
// and fires the callbacks when those happens.
|
||||||
// A call of its `FireNow` manually will fire and reset the registered interrupt handlers.
|
// A call of its `FireNow` manually will fire and reset the registered interrupt handlers.
|
||||||
var Interrupt = new(interruptListener)
|
var Interrupt TnterruptListener = new(interruptListener)
|
||||||
|
|
||||||
|
type TnterruptListener interface {
|
||||||
|
Register(cb func())
|
||||||
|
FireNow()
|
||||||
|
}
|
||||||
|
|
||||||
type interruptListener struct {
|
type interruptListener struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
2
iris.go
2
iris.go
|
@ -588,7 +588,7 @@ func (app *Application) NewHost(srv *http.Server) *host.Supervisor {
|
||||||
if !app.config.DisableInterruptHandler {
|
if !app.config.DisableInterruptHandler {
|
||||||
// when CTRL/CMD+C pressed.
|
// when CTRL/CMD+C pressed.
|
||||||
shutdownTimeout := 10 * time.Second
|
shutdownTimeout := 10 * time.Second
|
||||||
host.RegisterOnInterrupt(host.ShutdownOnInterrupt(su, shutdownTimeout))
|
RegisterOnInterrupt(host.ShutdownOnInterrupt(su, shutdownTimeout))
|
||||||
// app.logger.Debugf("Host: register server shutdown on interrupt(CTRL+C/CMD+C)")
|
// app.logger.Debugf("Host: register server shutdown on interrupt(CTRL+C/CMD+C)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user