Compare commits
5 Commits
v2024.5.29
...
v2024.6.5-
Author | SHA1 | Date | |
---|---|---|---|
65238f1ff3 | |||
d4da9cba8d | |||
d5fed4c2ac | |||
c7ac331b10 | |||
2952f68720 |
2
Makefile
2
Makefile
@ -59,7 +59,7 @@ deps: .env
|
|||||||
|
|
||||||
.PHONY: goreleaser
|
.PHONY: goreleaser
|
||||||
goreleaser: deps
|
goreleaser: deps
|
||||||
( set -o allexport && source .env && set +o allexport && VERSION=$(GORELEASER_VERSION) curl -sfL https://goreleaser.com/static/run | GORELEASER_CURRENT_TAG="$(FULL_VERSION)" bash /dev/stdin $(GORELEASER_ARGS) )
|
( set -o allexport && source .env && set +o allexport && curl -sfL https://goreleaser.com/static/run | VERSION=$(GORELEASER_VERSION) GORELEASER_CURRENT_TAG="$(FULL_VERSION)" bash /dev/stdin $(GORELEASER_ARGS) )
|
||||||
|
|
||||||
.PHONY: start-release
|
.PHONY: start-release
|
||||||
start-release:
|
start-release:
|
||||||
|
@ -206,7 +206,12 @@ func (id *InterpolatedDuration) UnmarshalYAML(value *yaml.Node) error {
|
|||||||
|
|
||||||
duration, err := time.ParseDuration(str)
|
duration, err := time.ParseDuration(str)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "could not parse duration '%v', line '%d'", str, value.Line)
|
nanoseconds, err := strconv.ParseInt(str, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "could not parse duration '%v', line '%d'", str, value.Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
duration = time.Duration(nanoseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
*id = InterpolatedDuration(duration)
|
*id = InterpolatedDuration(duration)
|
||||||
|
@ -31,6 +31,7 @@ type QueueLayerConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthnLayerConfig struct {
|
type AuthnLayerConfig struct {
|
||||||
|
Debug InterpolatedBool `yaml:"debug"`
|
||||||
TemplateDir InterpolatedString `yaml:"templateDir"`
|
TemplateDir InterpolatedString `yaml:"templateDir"`
|
||||||
OIDC AuthnOIDCLayerConfig `yaml:"oidc"`
|
OIDC AuthnOIDCLayerConfig `yaml:"oidc"`
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@ import (
|
|||||||
type Layer struct {
|
type Layer struct {
|
||||||
layerType store.LayerType
|
layerType store.LayerType
|
||||||
auth Authenticator
|
auth Authenticator
|
||||||
|
debug bool
|
||||||
|
|
||||||
templateDir string
|
templateDir string
|
||||||
}
|
}
|
||||||
@ -40,8 +41,9 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Error(ctx, "could not execute pre-auth hook", logger.E(errors.WithStack(err)))
|
err = errors.WithStack(err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
logger.Error(ctx, "could not execute pre-auth hook", logger.E(err))
|
||||||
|
l.renderErrorPage(w, r, layer, options, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -65,8 +67,9 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Error(ctx, "could not authenticate user", logger.E(errors.WithStack(err)))
|
err = errors.WithStack(err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
logger.Error(ctx, "could not authenticate user", logger.E(err))
|
||||||
|
l.renderErrorPage(w, r, layer, options, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -77,8 +80,9 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Error(ctx, "could not apply rules", logger.E(errors.WithStack(err)))
|
err = errors.WithStack(err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
logger.Error(ctx, "could not apply rules", logger.E(err))
|
||||||
|
l.renderErrorPage(w, r, layer, options, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -94,8 +98,9 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Error(ctx, "could not execute post-auth hook", logger.E(errors.WithStack(err)))
|
err = errors.WithStack(err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
logger.Error(ctx, "could not execute post-auth hook", logger.E(err))
|
||||||
|
l.renderErrorPage(w, r, layer, options, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -108,12 +113,47 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Layer) renderForbiddenPage(w http.ResponseWriter, r *http.Request, layer *store.Layer, options *LayerOptions, user *User) {
|
type baseTemplateData struct {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
Layer *store.Layer
|
||||||
l.renderPage(w, r, layer, "forbidden", options.Templates.Forbidden.Block, user)
|
Debug bool
|
||||||
|
Request *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Layer) renderPage(w http.ResponseWriter, r *http.Request, layer *store.Layer, page string, block string, user *User) {
|
func (l *Layer) renderForbiddenPage(w http.ResponseWriter, r *http.Request, layer *store.Layer, options *LayerOptions, user *User) {
|
||||||
|
templateData := struct {
|
||||||
|
baseTemplateData
|
||||||
|
User *User
|
||||||
|
}{
|
||||||
|
baseTemplateData: baseTemplateData{
|
||||||
|
Layer: layer,
|
||||||
|
Debug: l.debug,
|
||||||
|
Request: r,
|
||||||
|
},
|
||||||
|
User: user,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
l.renderPage(w, r, "forbidden", options.Templates.Forbidden.Block, templateData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Layer) renderErrorPage(w http.ResponseWriter, r *http.Request, layer *store.Layer, options *LayerOptions, err error) {
|
||||||
|
templateData := struct {
|
||||||
|
baseTemplateData
|
||||||
|
Err error
|
||||||
|
}{
|
||||||
|
baseTemplateData: baseTemplateData{
|
||||||
|
Layer: layer,
|
||||||
|
Debug: l.debug,
|
||||||
|
Request: r,
|
||||||
|
},
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
l.renderPage(w, r, "error", options.Templates.Error.Block, templateData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Layer) renderPage(w http.ResponseWriter, r *http.Request, page string, block string, templateData any) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
pattern := filepath.Join(l.templateDir, page+".gohtml")
|
pattern := filepath.Join(l.templateDir, page+".gohtml")
|
||||||
@ -128,18 +168,8 @@ func (l *Layer) renderPage(w http.ResponseWriter, r *http.Request, layer *store.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
templateData := struct {
|
|
||||||
Layer *store.Layer
|
|
||||||
User *User
|
|
||||||
}{
|
|
||||||
Layer: layer,
|
|
||||||
User: user,
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Add("Cache-Control", "no-cache")
|
w.Header().Add("Cache-Control", "no-cache")
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
if err := tmpl.ExecuteTemplate(w, block, templateData); err != nil {
|
if err := tmpl.ExecuteTemplate(w, block, templateData); err != nil {
|
||||||
logger.Error(ctx, "could not render authn page", logger.E(errors.WithStack(err)))
|
logger.Error(ctx, "could not render authn page", logger.E(errors.WithStack(err)))
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
@ -160,6 +190,7 @@ func NewLayer(layerType store.LayerType, auth Authenticator, funcs ...OptionFunc
|
|||||||
layerType: layerType,
|
layerType: layerType,
|
||||||
auth: auth,
|
auth: auth,
|
||||||
templateDir: opts.TemplateDir,
|
templateDir: opts.TemplateDir,
|
||||||
|
debug: opts.Debug,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ type LayerOptions struct {
|
|||||||
|
|
||||||
type TemplatesOptions struct {
|
type TemplatesOptions struct {
|
||||||
Forbidden TemplateOptions `mapstructure:"forbidden"`
|
Forbidden TemplateOptions `mapstructure:"forbidden"`
|
||||||
|
Error TemplateOptions `mapstructure:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemplateOptions struct {
|
type TemplateOptions struct {
|
||||||
@ -43,6 +44,9 @@ func DefaultLayerOptions() LayerOptions {
|
|||||||
Forbidden: TemplateOptions{
|
Forbidden: TemplateOptions{
|
||||||
Block: "default",
|
Block: "default",
|
||||||
},
|
},
|
||||||
|
Error: TemplateOptions{
|
||||||
|
Block: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
|
|||||||
return errors.WithStack(err)
|
return errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Name))
|
sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Proxy, layer.Name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(ctx, "could not retrieve session", logger.E(errors.WithStack(err)))
|
logger.Error(ctx, "could not retrieve session", logger.E(errors.WithStack(err)))
|
||||||
}
|
}
|
||||||
@ -121,7 +121,7 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
|
|||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Name))
|
sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Proxy, layer.Name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
@ -401,8 +401,8 @@ func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*C
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Authenticator) getCookieName(cookieName string, layerName store.LayerName) string {
|
func (a *Authenticator) getCookieName(cookieName string, proxyName store.ProxyName, layerName store.LayerName) string {
|
||||||
return fmt.Sprintf("%s_%s", cookieName, layerName)
|
return strings.ToLower(fmt.Sprintf("%s_%s_%s", cookieName, proxyName, layerName))
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -14,5 +14,5 @@ func NewLayer(store sessions.Store, funcs ...OptionFunc) *authn.Layer {
|
|||||||
httpTransport: opts.HTTPTransport,
|
httpTransport: opts.HTTPTransport,
|
||||||
httpClientTimeout: opts.HTTPClientTimeout,
|
httpClientTimeout: opts.HTTPClientTimeout,
|
||||||
store: store,
|
store: store,
|
||||||
})
|
}, opts.AuthnOptions...)
|
||||||
}
|
}
|
||||||
|
@ -3,11 +3,14 @@ package oidc
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
HTTPTransport *http.Transport
|
HTTPTransport *http.Transport
|
||||||
HTTPClientTimeout time.Duration
|
HTTPClientTimeout time.Duration
|
||||||
|
AuthnOptions []authn.OptionFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
type OptionFunc func(opts *Options)
|
type OptionFunc func(opts *Options)
|
||||||
@ -24,10 +27,17 @@ func WithHTTPClientTimeout(timeout time.Duration) OptionFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithAuthnOptions(funcs ...authn.OptionFunc) OptionFunc {
|
||||||
|
return func(opts *Options) {
|
||||||
|
opts.AuthnOptions = funcs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewOptions(funcs ...OptionFunc) *Options {
|
func NewOptions(funcs ...OptionFunc) *Options {
|
||||||
opts := &Options{
|
opts := &Options{
|
||||||
HTTPTransport: http.DefaultTransport.(*http.Transport),
|
HTTPTransport: http.DefaultTransport.(*http.Transport),
|
||||||
HTTPClientTimeout: 30 * time.Second,
|
HTTPClientTimeout: 30 * time.Second,
|
||||||
|
AuthnOptions: make([]authn.OptionFunc, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, fn := range funcs {
|
for _, fn := range funcs {
|
||||||
|
@ -2,6 +2,7 @@ package authn
|
|||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
TemplateDir string
|
TemplateDir string
|
||||||
|
Debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type OptionFunc func(*Options)
|
type OptionFunc func(*Options)
|
||||||
@ -9,6 +10,7 @@ type OptionFunc func(*Options)
|
|||||||
func NewOptions(funcs ...OptionFunc) *Options {
|
func NewOptions(funcs ...OptionFunc) *Options {
|
||||||
opts := &Options{
|
opts := &Options{
|
||||||
TemplateDir: "./templates",
|
TemplateDir: "./templates",
|
||||||
|
Debug: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, fn := range funcs {
|
for _, fn := range funcs {
|
||||||
@ -23,3 +25,9 @@ func WithTemplateDir(templateDir string) OptionFunc {
|
|||||||
o.TemplateDir = templateDir
|
o.TemplateDir = templateDir
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithDebug(debug bool) OptionFunc {
|
||||||
|
return func(o *Options) {
|
||||||
|
o.Debug = debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -10,7 +10,10 @@ type Status struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Adapter interface {
|
type Adapter interface {
|
||||||
|
// Touch updates the session TTL and returns its current rank
|
||||||
Touch(ctx context.Context, queueName string, sessionId string) (int64, error)
|
Touch(ctx context.Context, queueName string, sessionId string) (int64, error)
|
||||||
|
// Status returns the queue current status
|
||||||
Status(ctx context.Context, queueName string) (*Status, error)
|
Status(ctx context.Context, queueName string) (*Status, error)
|
||||||
|
// Refresh forces a refresh of the queue, taking into account the given TTL for sessions
|
||||||
Refresh(ctx context.Context, queueName string, keepAlive time.Duration) error
|
Refresh(ctx context.Context, queueName string, keepAlive time.Duration) error
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ func init() {
|
|||||||
func setupAuthnBasicLayer(conf *config.Config) (director.Layer, error) {
|
func setupAuthnBasicLayer(conf *config.Config) (director.Layer, error) {
|
||||||
options := []authn.OptionFunc{
|
options := []authn.OptionFunc{
|
||||||
authn.WithTemplateDir(string(conf.Layers.Authn.TemplateDir)),
|
authn.WithTemplateDir(string(conf.Layers.Authn.TemplateDir)),
|
||||||
|
authn.WithDebug(bool(conf.Layers.Authn.Debug)),
|
||||||
}
|
}
|
||||||
|
|
||||||
return basic.NewLayer(options...), nil
|
return basic.NewLayer(options...), nil
|
||||||
|
@ -21,6 +21,7 @@ func init() {
|
|||||||
func setupAuthnNetworkLayer(conf *config.Config) (director.Layer, error) {
|
func setupAuthnNetworkLayer(conf *config.Config) (director.Layer, error) {
|
||||||
options := []authn.OptionFunc{
|
options := []authn.OptionFunc{
|
||||||
authn.WithTemplateDir(string(conf.Layers.Authn.TemplateDir)),
|
authn.WithTemplateDir(string(conf.Layers.Authn.TemplateDir)),
|
||||||
|
authn.WithDebug(bool(conf.Layers.Authn.Debug)),
|
||||||
}
|
}
|
||||||
|
|
||||||
return network.NewLayer(options...), nil
|
return network.NewLayer(options...), nil
|
||||||
|
@ -33,5 +33,9 @@ func setupAuthnOIDCLayer(conf *config.Config) (director.Layer, error) {
|
|||||||
store,
|
store,
|
||||||
oidc.WithHTTPTransport(transport),
|
oidc.WithHTTPTransport(transport),
|
||||||
oidc.WithHTTPClientTimeout(time.Duration(*conf.Layers.Authn.OIDC.HTTPClient.Timeout)),
|
oidc.WithHTTPClientTimeout(time.Duration(*conf.Layers.Authn.OIDC.HTTPClient.Timeout)),
|
||||||
|
oidc.WithAuthnOptions(
|
||||||
|
authn.WithTemplateDir(string(conf.Layers.Authn.TemplateDir)),
|
||||||
|
authn.WithDebug(bool(conf.Layers.Authn.Debug)),
|
||||||
|
),
|
||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
104
layers/authn/templates/error.gohtml
Normal file
104
layers/authn/templates/error.gohtml
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
{{ define "default" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Erreur - {{ .Layer.Name }}</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*:before,
|
||||||
|
*:after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
p,
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
background-color: #ffe4e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#card {
|
||||||
|
padding: 1.5em 1em;
|
||||||
|
border: 1px solid #d90202;
|
||||||
|
background-color: #feb0b0;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 2px 2px #cccccc1c;
|
||||||
|
color: #810000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
font-size: 0.7em;
|
||||||
|
margin-top: 2em;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="container">
|
||||||
|
<div id="card">
|
||||||
|
<h2 class="title">Une erreur est survenue !</h2>
|
||||||
|
{{ if .Debug }}
|
||||||
|
<pre>{{ .Err }}</pre>
|
||||||
|
{{ end }}
|
||||||
|
{{/* if a public base url is provided, show navigation link */}}
|
||||||
|
{{ $oidc := ( index .Layer.Options "oidc" ) }}
|
||||||
|
{{ $publicBaseURL := ( index $oidc "publicBaseURL" ) }}
|
||||||
|
{{ if $publicBaseURL }}
|
||||||
|
<div class="back-link">
|
||||||
|
<a href="{{ $publicBaseURL }}" title="Retourner sur l'application"
|
||||||
|
>Retourner sur l'application</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<p class="footer">
|
||||||
|
Propulsé par
|
||||||
|
<a href="https://forge.cadoles.com/Cadoles/bouncer">Bouncer</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
Reference in New Issue
Block a user