Changed all the html into go using go-elem (#2161)

* Changed all the HTML into go using go-elem

            Created templates package in ./hscontrol/templates.
            Moved the registerWebAPITemplate into the templates package as a function to be called.

            Replaced the apple and windows html files with go-elem.

* update flake

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
Co-authored-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
Amha Mersha 2024-10-04 04:39:24 -07:00 committed by GitHub
parent 9515040161
commit 24e7851a40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 323 additions and 294 deletions

View File

@ -32,7 +32,7 @@
# When updating go.mod or go.sum, a new sha will need to be calculated, # When updating go.mod or go.sum, a new sha will need to be calculated,
# update this if you have a mismatch after doing a change to thos files. # update this if you have a mismatch after doing a change to thos files.
vendorHash = "sha256-SDJSFji6498WI9bJLmY62VGt21TtD2GxrxRAWyYyr0c="; vendorHash = "sha256-CMkYTRjmhvTTrB7JbLj0cj9VEyzpG0iUWXkaOagwYTk=";
subPackages = ["cmd/headscale"]; subPackages = ["cmd/headscale"];

1
go.mod
View File

@ -4,6 +4,7 @@ go 1.23.1
require ( require (
github.com/AlecAivazis/survey/v2 v2.3.7 github.com/AlecAivazis/survey/v2 v2.3.7
github.com/chasefleming/elem-go v0.29.0
github.com/coder/websocket v1.8.12 github.com/coder/websocket v1.8.12
github.com/coreos/go-oidc/v3 v3.11.0 github.com/coreos/go-oidc/v3 v3.11.0
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc

2
go.sum
View File

@ -90,6 +90,8 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chasefleming/elem-go v0.29.0 h1:WwrjQcVn6xldhexluvl2Z3sgKi9HTMuzWeEXO4PHsmg=
github.com/chasefleming/elem-go v0.29.0/go.mod h1:hz73qILBIKnTgOujnSMtEj20/epI+f6vg71RUilJAA4=
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=

View File

@ -1,17 +1,19 @@
package hscontrol package hscontrol
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/juanfont/headscale/hscontrol/templates"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/key" "tailscale.com/types/key"
@ -135,38 +137,37 @@ func (h *Headscale) HealthHandler(
respond(nil) respond(nil)
} }
type registerWebAPITemplateConfig struct { var codeStyleRegisterWebAPI = styles.Props{
Key string styles.Display: "block",
styles.Padding: "20px",
styles.Border: "1px solid #bbb",
styles.BackgroundColor: "#eee",
} }
var registerWebAPITemplate = template.Must( func registerWebHTML(key string) *elem.Element {
template.New("registerweb").Parse(` return elem.Html(nil,
<html> elem.Head(
<head> nil,
<title>Registration - Headscale</title> elem.Title(nil, elem.Text("Registration - Headscale")),
<meta name=viewport content="width=device-width, initial-scale=1"> elem.Meta(attrs.Props{
<style> attrs.Name: "viewport",
body { attrs.Content: "width=device-width, initial-scale=1",
font-family: sans; }),
),
elem.Body(attrs.Props{
attrs.Style: styles.Props{
styles.FontFamily: "sans",
}.ToInline(),
},
elem.H1(nil, elem.Text("headscale")),
elem.H2(nil, elem.Text("Machine registration")),
elem.P(nil, elem.Text("Run the command below in the headscale server to add this machine to your network:")),
elem.Code(attrs.Props{attrs.Style: codeStyleRegisterWebAPI.ToInline()},
elem.Text(fmt.Sprintf("headscale nodes register --user USERNAME --key %s", key)),
),
),
)
} }
code {
display: block;
padding: 20px;
border: 1px solid #bbb;
background-color: #eee;
}
</style>
</head>
<body>
<h1>headscale</h1>
<h2>Machine registration</h2>
<p>
Run the command below in the headscale server to add this machine to your network:
</p>
<code>headscale nodes register --user USERNAME --key {{.Key}}</code>
</body>
</html>
`))
type AuthProviderWeb struct { type AuthProviderWeb struct {
serverURL string serverURL string
@ -220,34 +221,14 @@ func (a *AuthProviderWeb) RegisterHandler(
return return
} }
var content bytes.Buffer
if err := registerWebAPITemplate.Execute(&content, registerWebAPITemplateConfig{
Key: machineKey.String(),
}); err != nil {
log.Error().
Str("func", "RegisterWebAPI").
Err(err).
Msg("Could not render register web API template")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
_, err = writer.Write([]byte("Could not render register web API template"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
return
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK) writer.WriteHeader(http.StatusOK)
_, err = writer.Write(content.Bytes()) if _, err := writer.Write([]byte(registerWebHTML(machineKey.String()).Render())); err != nil {
if err != nil { if _, err := writer.Write([]byte(templates.RegisterWeb(machineKey.String()).Render())); err != nil {
log.Error(). log.Error().
Caller(). Caller().
Err(err). Err(err).
Msg("Failed to write response") Msg("Failed to write response")
} }
} }
}

View File

@ -9,49 +9,19 @@ import (
"github.com/gofrs/uuid/v5" "github.com/gofrs/uuid/v5"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/juanfont/headscale/hscontrol/templates"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
//go:embed templates/apple.html
var appleTemplate string
//go:embed templates/windows.html
var windowsTemplate string
// WindowsConfigMessage shows a simple message in the browser for how to configure the Windows Tailscale client. // WindowsConfigMessage shows a simple message in the browser for how to configure the Windows Tailscale client.
func (h *Headscale) WindowsConfigMessage( func (h *Headscale) WindowsConfigMessage(
writer http.ResponseWriter, writer http.ResponseWriter,
req *http.Request, req *http.Request,
) { ) {
winTemplate := template.Must(template.New("windows").Parse(windowsTemplate))
config := map[string]interface{}{
"URL": h.cfg.ServerURL,
}
var payload bytes.Buffer
if err := winTemplate.Execute(&payload, config); err != nil {
log.Error().
Str("handler", "WindowsRegConfig").
Err(err).
Msg("Could not render Windows index template")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
_, err := writer.Write([]byte("Could not render Windows index template"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
return
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK) writer.WriteHeader(http.StatusOK)
_, err := writer.Write(payload.Bytes())
if err != nil { if _, err := writer.Write([]byte(templates.Windows(h.cfg.ServerURL).Render())); err != nil {
log.Error(). log.Error().
Caller(). Caller().
Err(err). Err(err).
@ -64,36 +34,10 @@ func (h *Headscale) AppleConfigMessage(
writer http.ResponseWriter, writer http.ResponseWriter,
req *http.Request, req *http.Request,
) { ) {
appleTemplate := template.Must(template.New("apple").Parse(appleTemplate))
config := map[string]interface{}{
"URL": h.cfg.ServerURL,
}
var payload bytes.Buffer
if err := appleTemplate.Execute(&payload, config); err != nil {
log.Error().
Str("handler", "AppleMobileConfig").
Err(err).
Msg("Could not render Apple index template")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
_, err := writer.Write([]byte("Could not render Apple index template"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
return
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(http.StatusOK) writer.WriteHeader(http.StatusOK)
_, err := writer.Write(payload.Bytes())
if err != nil { if _, err := writer.Write([]byte(templates.Apple(h.cfg.ServerURL).Render())); err != nil {
log.Error(). log.Error().
Caller(). Caller().
Err(err). Err(err).

View File

@ -0,0 +1,149 @@
package templates
import (
"fmt"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
)
func Apple(url string) *elem.Element {
return HtmlStructure(
elem.Title(nil,
elem.Text("headscale - Apple")),
elem.Body(attrs.Props{
attrs.Style: bodyStyle.ToInline(),
},
headerOne("headscale: iOS configuration"),
headerTwo("GUI"),
elem.Ol(nil,
elem.Li(nil,
elem.Text("Install the official Tailscale iOS client from the "),
elem.A(attrs.Props{attrs.Href: "https://apps.apple.com/app/tailscale/id1470499037"},
elem.Text("App store"),
),
),
elem.Li(nil,
elem.Text("Open Tailscale and make sure you are "),
elem.I(nil, elem.Text("not ")),
elem.Text("logged in to any account"),
),
elem.Li(nil,
elem.Text("Open Settings on the iOS device"),
),
elem.Li(nil,
elem.Text(`Scroll down to the "third party apps" section, under "Game Center" or "TV Provider"`),
),
elem.Li(nil,
elem.Text("Find Tailscale and select it"),
elem.Ul(nil,
elem.Li(nil,
elem.Text(`If the iOS device was previously logged into Tailscale, switch the "Reset Keychain" toggle to "on"`),
),
),
),
elem.Li(nil,
elem.Text(fmt.Sprintf(`Enter "%s" under "Alternate Coordination Server URL"`,url)),
),
elem.Li(nil,
elem.Text("Restart the app by closing it from the iOS app switcher, open the app and select the regular sign in option "),
elem.I(nil, elem.Text("(non-SSO)")),
elem.Text(". It should open up to the headscale authentication page."),
),
elem.Li(nil,
elem.Text("Enter your credentials and log in. Headscale should now be working on your iOS device"),
),
),
headerOne("headscale: macOS configuration"),
headerTwo("Command line"),
elem.P(nil,
elem.Text("Use Tailscale's login command to add your profile:"),
),
elem.Pre(nil,
elem.Code(nil,
elem.Text(fmt.Sprintf("tailscale login --login-server %s",url)),
),
),
headerTwo("GUI"),
elem.Ol(nil,
elem.Li(nil,
elem.Text("ALT + Click the Tailscale icon in the menu and hover over the Debug menu"),
),
elem.Li(nil,
elem.Text(`Under "Custom Login Server", select "Add Account..."`),
),
elem.Li(nil,
elem.Text(fmt.Sprintf(`Enter "%s" of the headscale instance and press "Add Account"`,url)),
),
elem.Li(nil,
elem.Text(`Follow the login procedure in the browser`),
),
),
headerTwo("Profiles"),
elem.P(nil,
elem.Text("Headscale can be set to the default server by installing a Headscale configuration profile:"),
),
elem.P(nil,
elem.A(attrs.Props{attrs.Href: "/apple/macos-app-store", attrs.Download: "headscale_macos.mobileconfig"},
elem.Text("macOS AppStore profile "),
),
elem.A(attrs.Props{attrs.Href: "/apple/macos-standalone", attrs.Download: "headscale_macos.mobileconfig"},
elem.Text("macOS Standalone profile"),
),
),
elem.Ol(nil,
elem.Li(nil,
elem.Text("Download the profile, then open it. When it has been opened, there should be a notification that a profile can be installed"),
),
elem.Li(nil,
elem.Text(`Open System Preferences and go to "Profiles"`),
),
elem.Li(nil,
elem.Text(`Find and install the Headscale profile`),
),
elem.Li(nil,
elem.Text(`Restart Tailscale.app and log in`),
),
),
elem.P(nil, elem.Text("Or")),
elem.P(nil,
elem.Text("Use your terminal to configure the default setting for Tailscale by issuing:"),
),
elem.Ul(nil,
elem.Li(nil,
elem.Text(`for app store client:`),
elem.Code(nil,
elem.Text(fmt.Sprintf(`defaults write io.tailscale.ipn.macos ControlURL %s`,url)),
),
),
elem.Li(nil,
elem.Text(`for standalone client:`),
elem.Code(nil,
elem.Text(fmt.Sprintf(`defaults write io.tailscale.ipn.macsys ControlURL %s`,url)),
),
),
),
elem.P(nil,
elem.Text("Restart Tailscale.app and log in."),
),
headerThree("Caution"),
elem.P(nil,
elem.Text("You should always download and inspect the profile before installing it:"),
),
elem.Ul(nil,
elem.Li(nil,
elem.Text(`for app store client: `),
elem.Code(nil,
elem.Text(fmt.Sprintf(`curl %s/apple/macos-app-store`,url)),
),
),
elem.Li(nil,
elem.Text(`for standalone client: `),
elem.Code(nil,
elem.Text(fmt.Sprintf(`curl %s/apple/macos-standalone`,url)),
),
),
),
),
)
}

View File

@ -1,131 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>headscale - Apple</title>
<style>
body {
margin: 40px auto;
max-width: 800px;
line-height: 1.5;
font-size: 16px;
color: #444;
padding: 0 10px;
font-family: Sans-serif;
}
h1,
h2,
h3 {
line-height: 1.2;
}
</style>
</head>
<body>
<h1>headscale: iOS configuration</h1>
<h2>GUI</h2>
<ol>
<li>
Install the official Tailscale iOS client from the
<a href="https://apps.apple.com/app/tailscale/id1470499037"
>App store</a
>
</li>
<li>
Open Tailscale and make sure you are <i>not</i> logged in to any account
</li>
<li>Open Settings on the iOS device</li>
<li>
Scroll down to the "third party apps" section, under "Game Center" or
"TV Provider"
</li>
<li>
Find Tailscale and select it
<ul>
<li>
If the iOS device was previously logged into Tailscale, switch the
"Reset Keychain" toggle to "on"
</li>
</ul>
</li>
<li>Enter "{{.URL}}" under "Alternate Coordination Server URL"</li>
<li>
Restart the app by closing it from the iOS app switcher, open the app
and select the regular sign in option <i>(non-SSO)</i>. It should open
up to the headscale authentication page.
</li>
<li>
Enter your credentials and log in. Headscale should now be working on
your iOS device
</li>
</ol>
<h1>headscale: macOS configuration</h1>
<h2>Command line</h2>
<p>Use Tailscale's login command to add your profile:</p>
<pre><code>tailscale login --login-server {{.URL}}</code></pre>
<h2>GUI</h2>
<ol>
<li>
ALT + Click the Tailscale icon in the menu and hover over the Debug menu
</li>
<li>Under "Custom Login Server", select "Add Account..."</li>
<li>
Enter "{{.URL}}" of the headscale instance and press "Add Account"
</li>
<li>Follow the login procedure in the browser</li>
</ol>
<h2>Profiles</h2>
<p>
Headscale can be set to the default server by installing a Headscale
configuration profile:
</p>
<p>
<a href="/apple/macos-app-store" download="headscale_macos.mobileconfig"
>macOS AppStore profile</a
>
<a href="/apple/macos-standalone" download="headscale_macos.mobileconfig"
>macOS Standalone profile</a
>
</p>
<ol>
<li>
Download the profile, then open it. When it has been opened, there
should be a notification that a profile can be installed
</li>
<li>Open System Preferences and go to "Profiles"</li>
<li>Find and install the Headscale profile</li>
<li>Restart Tailscale.app and log in</li>
</ol>
<p>Or</p>
<p>
Use your terminal to configure the default setting for Tailscale by
issuing:
</p>
<ul>
<li>
for app store client:
<code>defaults write io.tailscale.ipn.macos ControlURL {{.URL}}</code>
</li>
<li>
for standalone client:
<code>defaults write io.tailscale.ipn.macsys ControlURL {{.URL}}</code>
</li>
</ul>
<p>Restart Tailscale.app and log in.</p>
<h3>Caution</h3>
<p>
You should always download and inspect the profile before installing it:
</p>
<ul>
<li>
for app store client: <code>curl {{.URL}}/apple/macos-app-store</code>
</li>
<li>
for standalone client: <code>curl {{.URL}}/apple/macos-standalone</code>
</li>
</ul>
</body>
</html>

View File

@ -0,0 +1,56 @@
package templates
import (
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
)
var bodyStyle = styles.Props{
styles.Margin: "40px auto",
styles.MaxWidth: "800px",
styles.LineHeight: "1.5",
styles.FontSize: "16px",
styles.Color: "#444",
styles.Padding: "0 10px",
styles.FontFamily: "Sans-serif",
}
var headerStyle = styles.Props{
styles.LineHeight: "1.2",
}
func headerOne(text string) *elem.Element {
return elem.H1(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text))
}
func headerTwo(text string) *elem.Element {
return elem.H2(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text))
}
func headerThree(text string) *elem.Element {
return elem.H3(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text))
}
func HtmlStructure(head, body *elem.Element) *elem.Element {
return elem.Html(nil,
elem.Head(
attrs.Props{
attrs.Lang: "en",
},
elem.Meta(attrs.Props{
attrs.Charset: "UTF-8",
}),
elem.Meta(attrs.Props{
attrs.HTTPequiv: "X-UA-Compatible",
attrs.Content: "IE=edge",
}),
elem.Meta(attrs.Props{
attrs.Name: "viewport",
attrs.Content: "width=device-width, initial-scale=1.0",
}),
head,
),
body,
)
}

View File

@ -0,0 +1,34 @@
package templates
import (
"fmt"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
)
var codeStyleRegisterWebAPI = styles.Props{
styles.Display: "block",
styles.Padding: "20px",
styles.Border: "1px solid #bbb",
styles.BackgroundColor: "#eee",
}
func RegisterWeb(key string) *elem.Element {
return HtmlStructure(
elem.Title(nil, elem.Text("Registration - Headscale")),
elem.Body(attrs.Props{
attrs.Style: styles.Props{
styles.FontFamily: "sans",
}.ToInline(),
},
elem.H1(nil, elem.Text("headscale")),
elem.H2(nil, elem.Text("Machine registration")),
elem.P(nil, elem.Text("Run the command below in the headscale server to add this machine to your network: ")),
elem.Code(attrs.Props{attrs.Style: codeStyleRegisterWebAPI.ToInline()},
elem.Text(fmt.Sprintf("headscale nodes register --user USERNAME --key %s", key)),
),
),
)
}

View File

@ -0,0 +1,38 @@
package templates
import (
"fmt"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
)
func Windows(url string) *elem.Element {
return HtmlStructure(
elem.Title(nil,
elem.Text("headscale - Windows"),
),
elem.Body(attrs.Props{
attrs.Style : bodyStyle.ToInline(),
},
headerOne("headscale: Windows configuration"),
elem.P(nil,
elem.Text("Download "),
elem.A(attrs.Props{
attrs.Href: "https://tailscale.com/download/windows",
attrs.Rel: "noreferrer noopener",
attrs.Target: "_blank"},
elem.Text("Tailscale for Windows ")),
elem.Text("and install it."),
),
elem.P(nil,
elem.Text("Open a Command Prompt or Powershell and use Tailscale's login command to connect with headscale: "),
),
elem.Pre(nil,
elem.Code(nil,
elem.Text(fmt.Sprintf(`tailscale login --login-server %s`, url)),
),
),
),
)
}

View File

@ -1,45 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>headscale - Windows</title>
<style>
body {
margin: 40px auto;
max-width: 800px;
line-height: 1.5;
font-size: 16px;
color: #444;
padding: 0 10px;
font-family: Sans-serif;
}
h1,
h2,
h3 {
line-height: 1.2;
}
</style>
</head>
<body>
<h1>headscale: Windows configuration</h1>
<p>
Download
<a
href="https://tailscale.com/download/windows"
rel="noreferrer noopener"
target="_blank"
>Tailscale for Windows</a
>
and install it.
</p>
<p>
Open a Command Prompt or Powershell and use Tailscale's login command to
connect with headscale:
</p>
<pre><code>tailscale login --login-server {{.URL}}</code></pre>
</body>
</html>