mirror of
https://github.com/boringproxy/boringproxy.git
synced 2025-02-25 18:55:29 -06:00
Merge branch 'limit-tokens' of https://github.com/boringproxy/boringproxy
This commit is contained in:
commit
2563ecf6d3
174
api.go
174
api.go
@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Api struct {
|
||||
@ -29,6 +28,7 @@ func NewApi(config *Config, db *Database, auth *Auth, tunMan *TunnelManager) *Ap
|
||||
mux.Handle("/tunnels", http.StripPrefix("/tunnels", http.HandlerFunc(api.handleTunnels)))
|
||||
mux.Handle("/users/", http.StripPrefix("/users", http.HandlerFunc(api.handleUsers)))
|
||||
mux.Handle("/tokens/", http.StripPrefix("/tokens", http.HandlerFunc(api.handleTokens)))
|
||||
mux.Handle("/clients/", http.StripPrefix("/clients", http.HandlerFunc(api.handleClients)))
|
||||
|
||||
return api
|
||||
}
|
||||
@ -59,8 +59,24 @@ func (a *Api) handleTunnels(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tunnels := a.GetTunnels(tokenData)
|
||||
|
||||
if len(query["client-name"]) == 1 {
|
||||
clientName := query["client-name"][0]
|
||||
// If the token is limited to a specific client, filter out
|
||||
// tunnels for any other clients.
|
||||
if tokenData.Client != "" {
|
||||
for k, tun := range tunnels {
|
||||
if tokenData.Client != tun.ClientName {
|
||||
delete(tunnels, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clientName := query.Get("client-name")
|
||||
if clientName != "" && tokenData.Client != "" && clientName != tokenData.Client {
|
||||
w.WriteHeader(403)
|
||||
w.Write([]byte("Token is not valid for this client"))
|
||||
return
|
||||
}
|
||||
|
||||
if clientName != "" {
|
||||
for k, tun := range tunnels {
|
||||
if tun.ClientName != clientName {
|
||||
delete(tunnels, k)
|
||||
@ -85,6 +101,13 @@ func (a *Api) handleTunnels(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Write([]byte(body))
|
||||
case "POST":
|
||||
|
||||
if tokenData.Client != "" {
|
||||
w.WriteHeader(403)
|
||||
io.WriteString(w, "Token cannot be used to create tunnels")
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
_, err := a.CreateTunnel(tokenData, r.Form)
|
||||
if err != nil {
|
||||
@ -92,6 +115,12 @@ func (a *Api) handleTunnels(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
case "DELETE":
|
||||
if tokenData.Client != "" {
|
||||
w.WriteHeader(403)
|
||||
io.WriteString(w, "Token cannot be used to delete tunnels")
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
err := a.DeleteTunnel(tokenData, r.Form)
|
||||
if err != nil {
|
||||
@ -119,46 +148,25 @@ func (a *Api) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
parts := strings.Split(path[1:], "/")
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
if path == "/" {
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
err := a.CreateUser(tokenData, r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
return
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(405)
|
||||
io.WriteString(w, "Invalid method for /users")
|
||||
if tokenData.Client != "" {
|
||||
w.WriteHeader(403)
|
||||
io.WriteString(w, "Token cannot be used to create users")
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
err := a.CreateUser(tokenData, r.Form)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else if len(parts) == 3 && parts[1] == "clients" {
|
||||
ownerId := parts[0]
|
||||
clientId := parts[2]
|
||||
if r.Method == "PUT" {
|
||||
err := a.SetClient(tokenData, r.Form, ownerId, clientId)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else if r.Method == "DELETE" {
|
||||
err := a.DeleteClient(tokenData, ownerId, clientId)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
w.WriteHeader(400)
|
||||
io.WriteString(w, "Invalid /users/<username>/clients request")
|
||||
default:
|
||||
w.WriteHeader(405)
|
||||
io.WriteString(w, "Invalid method for /users")
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -178,6 +186,12 @@ func (a *Api) handleTokens(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if tokenData.Client != "" {
|
||||
w.WriteHeader(403)
|
||||
io.WriteString(w, "Token cannot be used to manage tokens")
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
r.ParseForm()
|
||||
@ -194,6 +208,66 @@ func (a *Api) handleTokens(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Api) handleClients(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
token, err := extractToken("access_token", r)
|
||||
if err != nil {
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte("No token provided"))
|
||||
return
|
||||
}
|
||||
|
||||
tokenData, exists := a.db.GetTokenData(token)
|
||||
if !exists {
|
||||
w.WriteHeader(403)
|
||||
w.Write([]byte("Not authorized"))
|
||||
return
|
||||
}
|
||||
|
||||
clientName := r.Form.Get("client-name")
|
||||
if clientName == "" {
|
||||
if tokenData.Client == "" {
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte("Missing client-name parameter"))
|
||||
return
|
||||
} else {
|
||||
clientName = tokenData.Client
|
||||
}
|
||||
}
|
||||
|
||||
if tokenData.Client != "" && tokenData.Client != clientName {
|
||||
w.WriteHeader(403)
|
||||
io.WriteString(w, "Token does not have proper permissions")
|
||||
return
|
||||
}
|
||||
|
||||
user := r.Form.Get("user")
|
||||
if user == "" {
|
||||
user = tokenData.Owner
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
err := a.SetClient(tokenData, r.Form, user, clientName)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
case "DELETE":
|
||||
err := a.DeleteClient(tokenData, user, clientName)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
return
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(405)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Api) GetTunnel(tokenData TokenData, params url.Values) (Tunnel, error) {
|
||||
domain := params.Get("domain")
|
||||
if domain == "" {
|
||||
@ -326,6 +400,7 @@ func (a *Api) CreateTunnel(tokenData TokenData, params url.Values) (*Tunnel, err
|
||||
}
|
||||
|
||||
func (a *Api) DeleteTunnel(tokenData TokenData, params url.Values) error {
|
||||
|
||||
domain := params.Get("domain")
|
||||
if domain == "" {
|
||||
return errors.New("Invalid domain parameter")
|
||||
@ -355,14 +430,23 @@ func (a *Api) CreateToken(tokenData TokenData, params url.Values) (string, error
|
||||
return "", errors.New("Invalid owner paramater")
|
||||
}
|
||||
|
||||
if tokenData.Owner != owner {
|
||||
user, _ := a.db.GetUser(tokenData.Owner)
|
||||
if !user.IsAdmin {
|
||||
return "", errors.New("Unauthorized")
|
||||
}
|
||||
user, _ := a.db.GetUser(tokenData.Owner)
|
||||
|
||||
if tokenData.Owner != owner && !user.IsAdmin {
|
||||
return "", errors.New("Unauthorized")
|
||||
}
|
||||
|
||||
token, err := a.db.AddToken(owner)
|
||||
client := params.Get("client")
|
||||
|
||||
if client != "any" {
|
||||
if _, exists := user.Clients[client]; !exists {
|
||||
return "", errors.New(fmt.Sprintf("Client %s does not exist for user %s", client, owner))
|
||||
}
|
||||
} else {
|
||||
client = ""
|
||||
}
|
||||
|
||||
token, err := a.db.AddToken(owner, client)
|
||||
if err != nil {
|
||||
return "", errors.New("Failed to create token")
|
||||
}
|
||||
|
@ -133,7 +133,7 @@ func Listen() {
|
||||
users := db.GetUsers()
|
||||
if len(users) == 0 {
|
||||
db.AddUser("admin", true)
|
||||
_, err := db.AddToken("admin")
|
||||
_, err := db.AddToken("admin", "any")
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize admin user")
|
||||
}
|
||||
@ -221,10 +221,15 @@ func Listen() {
|
||||
domain := namedropTokenData.Scopes[0].Domain
|
||||
host := namedropTokenData.Scopes[0].Host
|
||||
|
||||
recordType := "AAAA"
|
||||
if IsIPv4(config.PublicIp) {
|
||||
recordType = "A"
|
||||
}
|
||||
|
||||
createRecordReq := namedrop.Record{
|
||||
Domain: domain,
|
||||
Host: host,
|
||||
Type: "A",
|
||||
Type: recordType,
|
||||
Value: config.PublicIp,
|
||||
TTL: 300,
|
||||
}
|
||||
@ -421,3 +426,8 @@ func printLoginInfo(token, adminDomain string) {
|
||||
log.Println(fmt.Sprintf("Admin login link: %s", url))
|
||||
qrterminal.GenerateHalfBlock(url, qrterminal.L, os.Stdout)
|
||||
}
|
||||
|
||||
// Taken from https://stackoverflow.com/a/48519490/943814
|
||||
func IsIPv4(address string) bool {
|
||||
return strings.Count(address, ":") < 2
|
||||
}
|
||||
|
@ -115,8 +115,12 @@ func NewClient(config *ClientConfig) (*Client, error) {
|
||||
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
|
||||
url := fmt.Sprintf("https://%s/api/users/%s/clients/%s", c.server, c.user, c.clientName)
|
||||
clientReq, err := http.NewRequest("PUT", url, nil)
|
||||
url := fmt.Sprintf("https://%s/api/clients/?client-name=%s", c.server, c.clientName)
|
||||
if c.user != "" {
|
||||
url = url + "&user=" + c.user
|
||||
}
|
||||
|
||||
clientReq, err := http.NewRequest("POST", url, nil)
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("Failed to create request for URL %s", url))
|
||||
}
|
||||
|
@ -67,10 +67,6 @@ func main() {
|
||||
fail("-token is required")
|
||||
}
|
||||
|
||||
if *name == "" {
|
||||
fail("-client-name is required")
|
||||
}
|
||||
|
||||
config := &boringproxy.ClientConfig{
|
||||
ServerAddr: *server,
|
||||
Token: *token,
|
||||
|
10
database.go
10
database.go
@ -20,7 +20,8 @@ type Database struct {
|
||||
}
|
||||
|
||||
type TokenData struct {
|
||||
Owner string `json:"owner"`
|
||||
Owner string `json:"owner"`
|
||||
Client string `json:"client,omitempty"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@ -142,7 +143,7 @@ func (d *Database) DeleteDNSRequest(requestId string) {
|
||||
delete(d.dnsRequests, requestId)
|
||||
}
|
||||
|
||||
func (d *Database) AddToken(owner string) (string, error) {
|
||||
func (d *Database) AddToken(owner, client string) (string, error) {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
|
||||
@ -156,7 +157,10 @@ func (d *Database) AddToken(owner string) (string, error) {
|
||||
return "", errors.New("Could not generat token")
|
||||
}
|
||||
|
||||
d.Tokens[token] = TokenData{owner}
|
||||
d.Tokens[token] = TokenData{
|
||||
Owner: owner,
|
||||
Client: client,
|
||||
}
|
||||
|
||||
d.persist()
|
||||
|
||||
|
14
templates/add_token_client.tmpl
Normal file
14
templates/add_token_client.tmpl
Normal file
@ -0,0 +1,14 @@
|
||||
{{ template "header.tmpl" . }}
|
||||
<h1>Add Token</h1>
|
||||
<form action="/tokens" method="POST">
|
||||
<input type="hidden" name="owner" value="{{$.Owner}}">
|
||||
<label for="token-client">Limit to client:</label>
|
||||
<select id="token-client" name="client">
|
||||
<option value="any">No</option>
|
||||
{{range $clientName, $c := $.User.Clients}}
|
||||
<option value="{{$clientName}}">{{$clientName}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button class='button' type="submit">Submit</button>
|
||||
</form>
|
||||
{{ template "footer.tmpl" . }}
|
31
templates/clients.tmpl
Normal file
31
templates/clients.tmpl
Normal file
@ -0,0 +1,31 @@
|
||||
{{ template "header.tmpl" . }}
|
||||
<div class='list'>
|
||||
{{range $username, $user := .Users}}
|
||||
{{range $clientName, $client := $user.Clients}}
|
||||
|
||||
<div class='list-item'>
|
||||
<span class='client'>{{$clientName}} (Owner: {{$username}})</span>
|
||||
<a href="/confirm-delete-client?owner={{$username}}&client-name={{$clientName}}">
|
||||
<button class='button'>Delete</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class='client-adder'>
|
||||
<form action="/clients" method="POST">
|
||||
<label for="client-owner">Owner:</label>
|
||||
<select id="client-owner" name="owner">
|
||||
{{range $username, $user := .Users}}
|
||||
<option value="{{$username}}">{{$username}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<label for="client-name">Client Name:</label>
|
||||
<input type="text" name="client-name" required></input>
|
||||
<button class='button' type="submit">Add Client</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{ template "footer.tmpl" . }}
|
@ -26,6 +26,7 @@
|
||||
<a class='menu-item' href='/tunnels'>Tunnels</a>
|
||||
<a class='menu-item' href='/edit-tunnel'>Add Tunnel</a>
|
||||
<a class='menu-item' href='/tokens'>Tokens</a>
|
||||
<a class='menu-item' href='/clients'>Clients</a>
|
||||
{{ if $.User.IsAdmin }}
|
||||
<a class='menu-item' href='/users'>Users</a>
|
||||
{{ end }}
|
||||
|
@ -3,7 +3,11 @@
|
||||
{{range $token, $tokenData := .Tokens}}
|
||||
|
||||
<div class='list-item'>
|
||||
<span class='token'>{{$token}} ({{$tokenData.Owner}})</span>
|
||||
{{ if eq $tokenData.Client "" }}
|
||||
<span class='token'>{{$token}} (Owner: {{$tokenData.Owner}}) (Client: Any)</span>
|
||||
{{ else }}
|
||||
<span class='token'>{{$token}} (Owner: {{$tokenData.Owner}}) (Client: {{$tokenData.Client}})</span>
|
||||
{{ end }}
|
||||
<a href='/login?access_token={{$token}}'>Login link</a>
|
||||
<img class='qr-code' src='{{index $.QrCodes $token}}' width=100 height=100>
|
||||
<a href="/confirm-delete-token?token={{$token}}">
|
||||
@ -14,7 +18,7 @@
|
||||
</div>
|
||||
|
||||
<div class='token-adder'>
|
||||
<form action="/tokens" method="POST">
|
||||
<form action="/add-token-client" method="POST">
|
||||
<label for="token-owner">Owner:</label>
|
||||
<select id="token-owner" name="owner">
|
||||
{{range $username, $user := .Users}}
|
||||
|
132
ui_handler.go
132
ui_handler.go
@ -89,6 +89,12 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
if tokenData.Client != "" {
|
||||
w.WriteHeader(403)
|
||||
h.alertDialog(w, r, "This token is limited to a specific client and cannot be used for the web UI", "/")
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := h.db.GetUser(tokenData.Owner)
|
||||
|
||||
tunnels := h.api.GetTunnels(tokenData)
|
||||
@ -206,12 +212,39 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=id_rsa")
|
||||
io.WriteString(w, tun.TunnelPrivateKey)
|
||||
|
||||
case "/add-token-client":
|
||||
r.ParseForm()
|
||||
|
||||
owner := r.Form.Get("owner")
|
||||
|
||||
addTokenUser, _ := h.db.GetUser(owner)
|
||||
|
||||
templateData := struct {
|
||||
Owner string
|
||||
User User
|
||||
}{
|
||||
Owner: owner,
|
||||
User: addTokenUser,
|
||||
}
|
||||
|
||||
err := h.tmpl.ExecuteTemplate(w, "add_token_client.tmpl", templateData)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
return
|
||||
}
|
||||
case "/tokens":
|
||||
h.handleTokens(w, r, user, tokenData)
|
||||
case "/clients":
|
||||
h.handleClients(w, r, user, tokenData)
|
||||
case "/confirm-delete-token":
|
||||
h.confirmDeleteToken(w, r)
|
||||
case "/delete-token":
|
||||
h.deleteToken(w, r, tokenData)
|
||||
case "/confirm-delete-client":
|
||||
h.confirmDeleteClient(w, r)
|
||||
case "/delete-client":
|
||||
h.deleteClient(w, r, tokenData)
|
||||
case "/confirm-logout":
|
||||
|
||||
data := &ConfirmData{
|
||||
@ -375,6 +408,66 @@ func (h *WebUiHandler) handleTokens(w http.ResponseWriter, r *http.Request, user
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebUiHandler) handleClients(w http.ResponseWriter, r *http.Request, user User, tokenData TokenData) {
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
var users map[string]User
|
||||
|
||||
// TODO: handle security checks in api
|
||||
if user.IsAdmin {
|
||||
users = h.db.GetUsers()
|
||||
} else {
|
||||
user, _ := h.db.GetUser(tokenData.Owner)
|
||||
users = make(map[string]User)
|
||||
users[tokenData.Owner] = user
|
||||
}
|
||||
|
||||
clients := make(map[string]DbClient)
|
||||
|
||||
for _, user := range users {
|
||||
for clientName, client := range user.Clients {
|
||||
clients[clientName] = client
|
||||
}
|
||||
}
|
||||
|
||||
templateData := struct {
|
||||
User User
|
||||
Users map[string]User
|
||||
Clients map[string]DbClient
|
||||
}{
|
||||
User: user,
|
||||
Users: users,
|
||||
Clients: clients,
|
||||
}
|
||||
|
||||
err := h.tmpl.ExecuteTemplate(w, "clients.tmpl", templateData)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, err.Error())
|
||||
return
|
||||
}
|
||||
case "POST":
|
||||
|
||||
owner := r.Form.Get("owner")
|
||||
clientName := r.Form.Get("client-name")
|
||||
|
||||
err := h.api.SetClient(tokenData, r.Form, owner, clientName)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, err.Error(), "/clients")
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/clients", 303)
|
||||
default:
|
||||
w.WriteHeader(405)
|
||||
h.alertDialog(w, r, "Invalid method for tokens", "/tokens")
|
||||
return
|
||||
}
|
||||
}
|
||||
func (h *WebUiHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.Method != "GET" {
|
||||
@ -619,7 +712,6 @@ func (h *WebUiHandler) confirmDeleteToken(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebUiHandler) deleteToken(w http.ResponseWriter, r *http.Request, tokenData TokenData) {
|
||||
|
||||
r.ParseForm()
|
||||
@ -633,6 +725,44 @@ func (h *WebUiHandler) deleteToken(w http.ResponseWriter, r *http.Request, token
|
||||
http.Redirect(w, r, "/tokens", 303)
|
||||
}
|
||||
|
||||
func (h *WebUiHandler) confirmDeleteClient(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
owner := r.Form.Get("owner")
|
||||
clientName := r.Form.Get("client-name")
|
||||
|
||||
data := &ConfirmData{
|
||||
Head: h.headHtml,
|
||||
Message: fmt.Sprintf("Are you sure you want to delete client %s for user %s?", clientName, owner),
|
||||
ConfirmUrl: fmt.Sprintf("/delete-client?owner=%s&client-name=%s", owner, clientName),
|
||||
CancelUrl: "/clients",
|
||||
}
|
||||
|
||||
err := h.tmpl.ExecuteTemplate(w, "confirm.tmpl", data)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, err.Error(), "/clients")
|
||||
return
|
||||
}
|
||||
}
|
||||
func (h *WebUiHandler) deleteClient(w http.ResponseWriter, r *http.Request, tokenData TokenData) {
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
owner := r.Form.Get("owner")
|
||||
clientName := r.Form.Get("client-name")
|
||||
|
||||
err := h.api.DeleteClient(tokenData, owner, clientName)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
h.alertDialog(w, r, err.Error(), "/clients")
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/clients", 303)
|
||||
}
|
||||
|
||||
func (h *WebUiHandler) alertDialog(w http.ResponseWriter, r *http.Request, message, redirectUrl string) error {
|
||||
err := h.tmpl.ExecuteTemplate(w, "alert.tmpl", &AlertData{
|
||||
Head: h.headHtml,
|
||||
|
Loading…
Reference in New Issue
Block a user