Initial version.
This commit is contained in:
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
FROM alpine:3.7
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ADD ldap-pass-webui /app/ldap-pass-webui
|
||||||
|
ADD static /app/static
|
||||||
|
ADD templates /app/templates
|
||||||
|
RUN chmod +x /app/ldap-pass-webui
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT [ "/app/ldap-pass-webui" ]
|
21
Makefile
Normal file
21
Makefile
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
REPO=npenkov/docker-ldap-passwd-webui
|
||||||
|
VER=1.0
|
||||||
|
|
||||||
|
.PHONY: all build push
|
||||||
|
|
||||||
|
all: build docker push clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
GOOS=linux go build -o ldap-pass-webui main.go
|
||||||
|
|
||||||
|
docker:
|
||||||
|
@echo "Building docker image"
|
||||||
|
docker build -t ${REPO}:${VER} -t ${REPO}:latest .
|
||||||
|
|
||||||
|
push:
|
||||||
|
@echo "Pushing to dockerhub"
|
||||||
|
docker push ${REPO}:${VER}
|
||||||
|
docker push ${REPO}:latest
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf ldap-pass-webui
|
31
README.md
Normal file
31
README.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Web UI for LDAP changing password
|
||||||
|
|
||||||
|
WebUI Client capable of connecting to backend LDAP server and changing the users password.
|
||||||
|
|
||||||
|
![Screenshot](screenshots/index.png)
|
||||||
|
|
||||||
|
## Running in docker container
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d -p 8080:8080 --name ldap-passwd-webui \
|
||||||
|
-e LPW_TITLE="Change your global password for example.org" \
|
||||||
|
-e LPW_HOST="your_ldap_host" \
|
||||||
|
-e LPW_PORT="636" \
|
||||||
|
-e LPW_ENCRYPTED="true" \
|
||||||
|
-e LPW_START_TLS="false" \
|
||||||
|
-e LPW_SSL_SKIP_VERIFY="true" \
|
||||||
|
-e LPW_USER_DN="uid=%s,ou=people,dc=example,dc=org" \
|
||||||
|
-e LPW_USER_BASE="ou=people,dc=example,dc=org" \
|
||||||
|
npenkov/docker-ldap-passwd-webui:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building and tagging
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
* [Web UI for changing LDAP password - python](https://github.com/jirutka/ldap-passwd-webui)
|
||||||
|
* [Gitea](https://github.com/go-gitea/gitea)
|
130
app/ldap.go
Normal file
130
app/ldap.go
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/ldap.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecurityProtocol protocol type
|
||||||
|
type SecurityProtocol int
|
||||||
|
|
||||||
|
// Note: new type must be added at the end of list to maintain compatibility.
|
||||||
|
const (
|
||||||
|
SecurityProtocolUnencrypted SecurityProtocol = iota
|
||||||
|
SecurityProtocolLDAPS
|
||||||
|
SecurityProtocolStartTLS
|
||||||
|
)
|
||||||
|
|
||||||
|
// LDAPClient Basic LDAP authentication service
|
||||||
|
type LDAPClient struct {
|
||||||
|
Name string // canonical name (ie. corporate.ad)
|
||||||
|
Host string // LDAP host
|
||||||
|
Port int // port number
|
||||||
|
SecurityProtocol SecurityProtocol
|
||||||
|
SkipVerify bool
|
||||||
|
UserBase string // Base search path for users
|
||||||
|
UserDN string // Template for the DN of the user for simple auth
|
||||||
|
Enabled bool // if this LDAPClient is disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindUser(l *ldap.Conn, userDN, passwd string) error {
|
||||||
|
log.Printf("\nBinding with userDN: %s", userDN)
|
||||||
|
err := l.Bind(userDN, passwd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("\nLDAP auth. failed for %s, reason: %v", userDN, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("\nBound successfully with userDN: %s", userDN)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ls *LDAPClient) sanitizedUserDN(username string) (string, bool) {
|
||||||
|
// See http://tools.ietf.org/search/rfc4514: "special characters"
|
||||||
|
badCharacters := "\x00()*\\,='\"#+;<>"
|
||||||
|
if strings.ContainsAny(username, badCharacters) {
|
||||||
|
log.Printf("\n'%s' contains invalid DN characters. Aborting.", username)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(ls.UserDN, username), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dial(ls *LDAPClient) (*ldap.Conn, error) {
|
||||||
|
log.Printf("\nDialing LDAP with security protocol (%v) without verifying: %v", ls.SecurityProtocol, ls.SkipVerify)
|
||||||
|
|
||||||
|
tlsCfg := &tls.Config{
|
||||||
|
ServerName: ls.Host,
|
||||||
|
InsecureSkipVerify: ls.SkipVerify,
|
||||||
|
}
|
||||||
|
if ls.SecurityProtocol == SecurityProtocolLDAPS {
|
||||||
|
return ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", ls.Host, ls.Port), tlsCfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", ls.Host, ls.Port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Dial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ls.SecurityProtocol == SecurityProtocolStartTLS {
|
||||||
|
if err = conn.StartTLS(tlsCfg); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("StartTLS: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModifyPassword : modify user's password
|
||||||
|
func (ls *LDAPClient) ModifyPassword(name, passwd, newPassword string) error {
|
||||||
|
if len(passwd) == 0 {
|
||||||
|
return fmt.Errorf("Auth. failed for %s, password cannot be empty", name)
|
||||||
|
}
|
||||||
|
l, err := dial(ls)
|
||||||
|
if err != nil {
|
||||||
|
ls.Enabled = false
|
||||||
|
return fmt.Errorf("LDAP Connect error, %s:%v", ls.Host, err)
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
|
||||||
|
var userDN string
|
||||||
|
log.Printf("\nLDAP will bind directly via UserDN template: %s", ls.UserDN)
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
userDN, ok = ls.sanitizedUserDN(name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("Error sanitizing name %s", name)
|
||||||
|
}
|
||||||
|
bindUser(l, userDN, passwd)
|
||||||
|
|
||||||
|
log.Printf("\nLDAP will execute password change on: %s", userDN)
|
||||||
|
req := ldap.NewPasswordModifyRequest(userDN, passwd, newPassword)
|
||||||
|
_, err = l.PasswordModify(req)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLDAPClient : Creates new LDAPClient capable of binding and changing passwords
|
||||||
|
func NewLDAPClient() *LDAPClient {
|
||||||
|
|
||||||
|
securityProtocol := SecurityProtocolUnencrypted
|
||||||
|
if envBool("LPW_ENCRYPTED", true) {
|
||||||
|
securityProtocol = SecurityProtocolLDAPS
|
||||||
|
if envBool("LPW_START_TLS", false) {
|
||||||
|
securityProtocol = SecurityProtocolStartTLS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LDAPClient{
|
||||||
|
Host: envStr("LPW_HOST", ""),
|
||||||
|
Port: envInt("LPW_PORT", 636), // 389
|
||||||
|
SecurityProtocol: securityProtocol,
|
||||||
|
SkipVerify: envBool("LPW_SSL_SKIP_VERIFY", false),
|
||||||
|
UserDN: envStr("LPW_USER_DN", "uid=%s,ou=people,dc=example,dc=org"),
|
||||||
|
UserBase: envStr("LPW_USER_BASE", "ou=people,dc=example,dc=org"),
|
||||||
|
}
|
||||||
|
}
|
42
app/util.go
Normal file
42
app/util.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getTitle() string {
|
||||||
|
return envStr("LPW_TITLE", "Change your password on example.org")
|
||||||
|
}
|
||||||
|
|
||||||
|
func envStr(key, defaultValue string) string {
|
||||||
|
val := os.Getenv(key)
|
||||||
|
if val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(key string, defaultValue int) int {
|
||||||
|
val := os.Getenv(key)
|
||||||
|
if val != "" {
|
||||||
|
i, err := strconv.Atoi(val)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func envBool(key string, defaultValue bool) bool {
|
||||||
|
val := os.Getenv(key)
|
||||||
|
if val != "" {
|
||||||
|
b, err := strconv.ParseBool(val)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
113
app/web.go
Normal file
113
app/web.go
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"html/template"
|
||||||
|
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type route struct {
|
||||||
|
pattern *regexp.Regexp
|
||||||
|
verb string
|
||||||
|
handler http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegexpHandler struct {
|
||||||
|
routes []*route
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RegexpHandler) Handler(pattern *regexp.Regexp, verb string, handler http.Handler) {
|
||||||
|
h.routes = append(h.routes, &route{pattern, verb, handler})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RegexpHandler) HandleFunc(r string, v string, handler func(http.ResponseWriter, *http.Request)) {
|
||||||
|
re := regexp.MustCompile(r)
|
||||||
|
h.routes = append(h.routes, &route{re, v, http.HandlerFunc(handler)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RegexpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
for _, route := range h.routes {
|
||||||
|
if route.pattern.MatchString(r.URL.Path) && route.verb == r.Method {
|
||||||
|
route.handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type pageData struct {
|
||||||
|
Title string
|
||||||
|
Username string
|
||||||
|
Alerts map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeAssets : Serves the static assets
|
||||||
|
func ServeAssets(w http.ResponseWriter, req *http.Request) {
|
||||||
|
http.ServeFile(w, req, path.Join("static", req.URL.Path[1:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeIndex : Serves index page on GET request
|
||||||
|
func ServeIndex(w http.ResponseWriter, req *http.Request) {
|
||||||
|
p := &pageData{Title: getTitle()}
|
||||||
|
t, e := template.ParseFiles(path.Join("templates", "index.html"))
|
||||||
|
if e != nil {
|
||||||
|
log.Printf("Error parsing file %v\n", e)
|
||||||
|
} else {
|
||||||
|
t.Execute(w, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword : Serves index page on POST request - executes the change
|
||||||
|
func ChangePassword(w http.ResponseWriter, req *http.Request) {
|
||||||
|
req.ParseForm()
|
||||||
|
un := ""
|
||||||
|
username := req.Form["username"]
|
||||||
|
oldPassword := req.Form["old-password"]
|
||||||
|
newPassword := req.Form["new-password"]
|
||||||
|
confirmPassword := req.Form["confirm-password"]
|
||||||
|
|
||||||
|
alerts := map[string]string{}
|
||||||
|
|
||||||
|
if len(username) < 1 || username[0] == "" {
|
||||||
|
alerts["error"] = "Username not specified.<br/>"
|
||||||
|
} else {
|
||||||
|
un = username[0]
|
||||||
|
}
|
||||||
|
if len(oldPassword) < 1 || oldPassword[0] == "" {
|
||||||
|
alerts["error"] = alerts["error"] + "Old password not specified.<br/>"
|
||||||
|
}
|
||||||
|
if len(newPassword) < 1 || newPassword[0] == "" {
|
||||||
|
alerts["error"] = alerts["error"] + "New password not specified.<br/>"
|
||||||
|
}
|
||||||
|
if len(confirmPassword) < 1 || confirmPassword[0] == "" {
|
||||||
|
alerts["error"] = alerts["error"] + "Confirmation password not specified.<br/>"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(confirmPassword) >= 1 && len(newPassword) >= 1 && strings.Compare(newPassword[0], confirmPassword[0]) != 0 {
|
||||||
|
alerts["error"] = alerts["error"] + "New and confirmation passwords does not match.<br/>"
|
||||||
|
}
|
||||||
|
if len(alerts) == 0 {
|
||||||
|
client := NewLDAPClient()
|
||||||
|
if err := client.ModifyPassword(un, oldPassword[0], newPassword[0]); err != nil {
|
||||||
|
alerts["error"] = fmt.Sprintf("%v", err)
|
||||||
|
} else {
|
||||||
|
alerts["success"] = "Password successfuly changed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &pageData{Title: getTitle(), Alerts: alerts, Username: un}
|
||||||
|
|
||||||
|
t, e := template.ParseFiles(path.Join("templates", "index.html"))
|
||||||
|
if e != nil {
|
||||||
|
log.Printf("Error parsing file %v\n", e)
|
||||||
|
} else {
|
||||||
|
t.Execute(w, p)
|
||||||
|
}
|
||||||
|
}
|
18
main.go
Normal file
18
main.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/npenkov/ldap-passwd-webui/app"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
reHandler := new(app.RegexpHandler)
|
||||||
|
|
||||||
|
reHandler.HandleFunc(".*.[js|css|png|eof|svg|ttf|woff]", "GET", app.ServeAssets)
|
||||||
|
reHandler.HandleFunc("/", "GET", app.ServeIndex)
|
||||||
|
reHandler.HandleFunc("/", "POST", app.ChangePassword)
|
||||||
|
|
||||||
|
fmt.Println("Starting server on port 8080")
|
||||||
|
http.ListenAndServe(":8080", reHandler)
|
||||||
|
}
|
BIN
screenshots/index.png
Normal file
BIN
screenshots/index.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
112
static/style.css
Normal file
112
static/style.css
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/* TODO make it cooler! */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin-bottom: 2.5em;
|
||||||
|
margin-top: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 16rem;
|
||||||
|
padding: 2rem 2.5rem 1.5rem 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background-color: #FAFAFA;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
box-shadow: inset 0 1px 3px #DDD;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
background-color: #FFF;
|
||||||
|
border-color: #51A7E8;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075) inset, 0 0 5px rgba(81, 167, 232, 0.5);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: #666;
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 1em 0 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #60B044;
|
||||||
|
background-image: linear-gradient(#8ADD6D, #60B044);
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
border: 1px solid #5CA941;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 2em 0 0.5em 0;
|
||||||
|
padding: 0.5em 0.7em;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3);
|
||||||
|
user-select: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus,
|
||||||
|
button:hover {
|
||||||
|
background-color: #569E3D;
|
||||||
|
background-image: linear-gradient(#79D858, #569E3D);
|
||||||
|
border-color: #4A993E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts {
|
||||||
|
margin: 2rem auto 0 auto;
|
||||||
|
max-width: 30rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
border: 1px solid;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.7em 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.error {
|
||||||
|
background-color: #E74C3C;
|
||||||
|
border-color: #C0392B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.success {
|
||||||
|
background-color: #60B044;
|
||||||
|
border-color: #5CA941;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media only screen and (max-width: 480px) {
|
||||||
|
|
||||||
|
form {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
43
templates/index.html
Normal file
43
templates/index.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" value="{{.Username}}" type="text" required autofocus>
|
||||||
|
|
||||||
|
<label for="old-password">Old password</label>
|
||||||
|
<input id="old-password" name="old-password" type="password" required>
|
||||||
|
|
||||||
|
<label for="new-password">New password</label>
|
||||||
|
<input id="new-password" name="new-password" type="password"
|
||||||
|
pattern=".{8,}" x-moz-errormessage="Password must be at least 8 characters long." required>
|
||||||
|
|
||||||
|
<label for="confirm-password">Confirm new password</label>
|
||||||
|
<input id="confirm-password" name="confirm-password" type="password"
|
||||||
|
pattern=".{8,}" x-moz-errormessage="Password must be at least 8 characters long." required>
|
||||||
|
|
||||||
|
<button type="submit">Update password</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="alerts">
|
||||||
|
{{ range $key, $value := .Alerts }}
|
||||||
|
<div class="alert {{ $key }}">{{ $value }}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
Reference in New Issue
Block a user