Create settings page placeholder, add auth pattern
This adds a pattern for creating pages that require authentication. The settings page is currently empty, but it's helpful to figure out how to do this pattern.
This commit is contained in:
parent
ae4be91d52
commit
20186f65bf
8 changed files with 288 additions and 18 deletions
64
auth.go
64
auth.go
|
|
@ -19,6 +19,10 @@ type NoCredentialsError struct{}
|
||||||
|
|
||||||
func (e NoCredentialsError) Error() string { return "No credentials were present in the request" }
|
func (e NoCredentialsError) Error() string { return "No credentials were present in the request" }
|
||||||
|
|
||||||
|
type NoUserError struct{}
|
||||||
|
|
||||||
|
func (e NoUserError) Error() string { return "That user does not exist" }
|
||||||
|
|
||||||
type InvalidCredentials struct{}
|
type InvalidCredentials struct{}
|
||||||
|
|
||||||
func (e InvalidCredentials) Error() string { return "No username with that password exists" }
|
func (e InvalidCredentials) Error() string { return "No username with that password exists" }
|
||||||
|
|
@ -27,6 +31,49 @@ type InvalidUsername struct{}
|
||||||
|
|
||||||
func (e InvalidUsername) Error() string { return "That username doesn't exist" }
|
func (e InvalidUsername) Error() string { return "That username doesn't exist" }
|
||||||
|
|
||||||
|
type AuthenticatedHandler func(http.ResponseWriter, *http.Request, *models.User)
|
||||||
|
type EnsureAuth struct {
|
||||||
|
handler AuthenticatedHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnsureAuth(handlerToWrap AuthenticatedHandler) *EnsureAuth {
|
||||||
|
return &EnsureAuth{handlerToWrap}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// If this is an API request respond with a more machine-readable error state
|
||||||
|
accept := r.Header.Values("Accept")
|
||||||
|
offers := []string{"application/json", "text/html"}
|
||||||
|
|
||||||
|
content_type := NegotiateContent(accept, offers)
|
||||||
|
user, err := getAuthenticatedUser(r)
|
||||||
|
if err != nil {
|
||||||
|
var msg []byte
|
||||||
|
// Separate return codes for different authentication failures
|
||||||
|
if _, ok := err.(*NoCredentialsError); ok {
|
||||||
|
fmt.Println("No credentials present and no session")
|
||||||
|
w.Header().Set("WWW-Authenticate-Error", "no-credentials")
|
||||||
|
msg = []byte("Please provide credentials.\n")
|
||||||
|
} else if _, ok := err.(*NoUserError); ok {
|
||||||
|
w.Header().Set("WWW-Authenticate-Error", "invalid-credentials")
|
||||||
|
msg = []byte("Invalid credentials provided.\n")
|
||||||
|
} else if _, ok := err.(*InvalidCredentials); ok {
|
||||||
|
w.Header().Set("WWW-Authenticate-Error", "invalid-credentials")
|
||||||
|
msg = []byte("Invalid credentials provided.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_type == "text/html" {
|
||||||
|
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="Nidus Sync"`)
|
||||||
|
w.WriteHeader(401)
|
||||||
|
w.Write(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ea.handler(w, r, user)
|
||||||
|
}
|
||||||
func addUserSession(r *http.Request, user *models.User) {
|
func addUserSession(r *http.Request, user *models.User) {
|
||||||
id := strconv.Itoa(int(user.ID))
|
id := strconv.Itoa(int(user.ID))
|
||||||
sessionManager.Put(r.Context(), "user_id", id)
|
sessionManager.Put(r.Context(), "user_id", id)
|
||||||
|
|
@ -36,6 +83,21 @@ func addUserSession(r *http.Request, user *models.User) {
|
||||||
slog.String("user_id", id))
|
slog.String("user_id", id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to translate strings into solid error types for operating on
|
||||||
|
func findUser(ctx context.Context, user_id int) (*models.User, error) {
|
||||||
|
user, err := models.FindUser(ctx, PGInstance.BobDB, int32(user_id))
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "No such user" {
|
||||||
|
return nil, &NoUserError{}
|
||||||
|
} else {
|
||||||
|
LogErrorTypeInfo(err)
|
||||||
|
slog.Error("Unrecognized error. This should be updated in the findUser code", slog.String("err", err.Error()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
func getAuthenticatedUser(r *http.Request) (*models.User, error) {
|
func getAuthenticatedUser(r *http.Request) (*models.User, error) {
|
||||||
//user_id := sessionManager.GetInt(r.Context(), "user_id")
|
//user_id := sessionManager.GetInt(r.Context(), "user_id")
|
||||||
user_id_str := sessionManager.GetString(r.Context(), "user_id")
|
user_id_str := sessionManager.GetString(r.Context(), "user_id")
|
||||||
|
|
@ -49,7 +111,7 @@ func getAuthenticatedUser(r *http.Request) (*models.User, error) {
|
||||||
slog.Int("user_id", user_id),
|
slog.Int("user_id", user_id),
|
||||||
slog.String("username", username))
|
slog.String("username", username))
|
||||||
if user_id > 0 && username != "" {
|
if user_id > 0 && username != "" {
|
||||||
return models.FindUser(r.Context(), PGInstance.BobDB, int32(user_id))
|
return findUser(r.Context(), user_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If we can't get the user from the session try to get from auth headers
|
// If we can't get the user from the session try to get from auth headers
|
||||||
|
|
|
||||||
156
content_negotiation.go
Normal file
156
content_negotiation.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// acceptValue represents a parsed accept header value
|
||||||
|
type acceptValue struct {
|
||||||
|
value string // the media type or value
|
||||||
|
quality float64 // quality factor (0.0 to 1.0)
|
||||||
|
specificity int // specificity level for sorting
|
||||||
|
order int // original order in the accept header
|
||||||
|
}
|
||||||
|
|
||||||
|
// NegotiateContent returns the best content to offer from a set of possible
|
||||||
|
// values, based on the preferences represented by the accept values.
|
||||||
|
func NegotiateContent(accepts []string, offers []string) string {
|
||||||
|
if len(offers) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no accept values, return first offer
|
||||||
|
if len(accepts) == 0 {
|
||||||
|
return offers[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse accept values (limit to first 32 to avoid DOS)
|
||||||
|
acceptValues := parseAcceptValues(accepts)
|
||||||
|
if len(acceptValues) > 32 {
|
||||||
|
acceptValues = acceptValues[:32]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find best match
|
||||||
|
bestMatch := ""
|
||||||
|
bestScore := -1.0
|
||||||
|
bestSpecificity := -1
|
||||||
|
bestOrder := len(offers) // use offer order as tiebreaker
|
||||||
|
|
||||||
|
for offerIdx, offer := range offers {
|
||||||
|
score, specificity := calculateScore(offer, acceptValues)
|
||||||
|
|
||||||
|
// Check if this is a better match
|
||||||
|
if score > bestScore ||
|
||||||
|
(score == bestScore && specificity > bestSpecificity) ||
|
||||||
|
(score == bestScore && specificity == bestSpecificity && offerIdx < bestOrder) {
|
||||||
|
bestMatch = offer
|
||||||
|
bestScore = score
|
||||||
|
bestSpecificity = specificity
|
||||||
|
bestOrder = offerIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestScore <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAcceptValues parses accept header values into structured format
|
||||||
|
func parseAcceptValues(accepts []string) []acceptValue {
|
||||||
|
var values []acceptValue
|
||||||
|
order := 0
|
||||||
|
|
||||||
|
for _, accept := range accepts {
|
||||||
|
// Split by comma to handle multiple values in one string
|
||||||
|
parts := strings.Split(accept, ",")
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
value := acceptValue{
|
||||||
|
quality: 1.0, // default quality
|
||||||
|
order: order,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by semicolon to separate value from parameters
|
||||||
|
sections := strings.Split(part, ";")
|
||||||
|
value.value = strings.TrimSpace(sections[0])
|
||||||
|
|
||||||
|
// Parse quality parameter
|
||||||
|
for i := 1; i < len(sections); i++ {
|
||||||
|
param := strings.TrimSpace(sections[i])
|
||||||
|
if strings.HasPrefix(param, "q=") {
|
||||||
|
if q, err := strconv.ParseFloat(param[2:], 64); err == nil {
|
||||||
|
if q >= 0.0 && q <= 1.0 {
|
||||||
|
value.quality = q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate specificity for media types
|
||||||
|
value.specificity = calculateSpecificity(value.value)
|
||||||
|
|
||||||
|
values = append(values, value)
|
||||||
|
order++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by quality (desc), then specificity (desc), then order (asc)
|
||||||
|
sort.Slice(values, func(i, j int) bool {
|
||||||
|
if values[i].quality != values[j].quality {
|
||||||
|
return values[i].quality > values[j].quality
|
||||||
|
}
|
||||||
|
if values[i].specificity != values[j].specificity {
|
||||||
|
return values[i].specificity > values[j].specificity
|
||||||
|
}
|
||||||
|
return values[i].order < values[j].order
|
||||||
|
})
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateSpecificity returns specificity level for media type matching
|
||||||
|
func calculateSpecificity(mediaType string) int {
|
||||||
|
if mediaType == "*/*" {
|
||||||
|
return 0 // least specific
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(mediaType, "/*") {
|
||||||
|
return 1 // type wildcard
|
||||||
|
}
|
||||||
|
return 2 // exact match (most specific)
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateScore returns the quality score and specificity for an offer against accept values
|
||||||
|
func calculateScore(offer string, acceptValues []acceptValue) (float64, int) {
|
||||||
|
for _, accept := range acceptValues {
|
||||||
|
if matches(offer, accept.value) {
|
||||||
|
return accept.quality, accept.specificity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0.0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// matches checks if an offer matches an accept value (including wildcards)
|
||||||
|
func matches(offer, accept string) bool {
|
||||||
|
if accept == "*/*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(accept, "/*") {
|
||||||
|
// Type wildcard (e.g., "text/*")
|
||||||
|
offerType := strings.Split(offer, "/")[0]
|
||||||
|
acceptType := strings.Split(accept, "/")[0]
|
||||||
|
return offerType == acceptType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
return offer == accept
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/models"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/skip2/go-qrcode"
|
"github.com/skip2/go-qrcode"
|
||||||
)
|
)
|
||||||
|
|
@ -209,6 +210,9 @@ func getServiceRequestUpdates(w http.ResponseWriter, r *http.Request) {
|
||||||
htmlServiceRequestUpdates(w)
|
htmlServiceRequestUpdates(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getSettings(w http.ResponseWriter, r *http.Request, u *models.User) {
|
||||||
|
htmlSettings(w, r, u)
|
||||||
|
}
|
||||||
func getSignup(w http.ResponseWriter, r *http.Request) {
|
func getSignup(w http.ResponseWriter, r *http.Request) {
|
||||||
htmlSignup(w, r.URL.Path)
|
htmlSignup(w, r.URL.Path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
html.go
52
html.go
|
|
@ -25,9 +25,15 @@ import (
|
||||||
//go:embed templates/*
|
//go:embed templates/*
|
||||||
var embeddedFiles embed.FS
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
|
// Authenticated pages
|
||||||
|
var (
|
||||||
|
dashboard = newBuiltTemplate("dashboard", "authenticated")
|
||||||
|
oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated")
|
||||||
|
settings = newBuiltTemplate("settings", "authenticated")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unauthenticated pages
|
||||||
var (
|
var (
|
||||||
dashboard = newBuiltTemplate("dashboard", "authenticated")
|
|
||||||
oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated")
|
|
||||||
phoneCall = newBuiltTemplate("phone-call", "base")
|
phoneCall = newBuiltTemplate("phone-call", "base")
|
||||||
report = newBuiltTemplate("report", "base")
|
report = newBuiltTemplate("report", "base")
|
||||||
reportConfirmation = newBuiltTemplate("report-confirmation", "base")
|
reportConfirmation = newBuiltTemplate("report-confirmation", "base")
|
||||||
|
|
@ -59,6 +65,9 @@ type Link struct {
|
||||||
Href string
|
Href string
|
||||||
Title string
|
Title string
|
||||||
}
|
}
|
||||||
|
type ContentAuthenticatedPlaceholder struct {
|
||||||
|
User User
|
||||||
|
}
|
||||||
type ContentPhoneCall struct {
|
type ContentPhoneCall struct {
|
||||||
DistrictName string
|
DistrictName string
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +138,19 @@ func bigNumber(n int) string {
|
||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contentForUser(ctx context.Context, user *models.User) (User, error) {
|
||||||
|
notifications, err := notificationsForUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return User{
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
Initials: extractInitials(user.DisplayName),
|
||||||
|
Notifications: notifications,
|
||||||
|
Username: user.Username,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
}
|
||||||
func extractInitials(name string) string {
|
func extractInitials(name string) string {
|
||||||
parts := strings.Fields(name)
|
parts := strings.Fields(name)
|
||||||
var initials strings.Builder
|
var initials strings.Builder
|
||||||
|
|
@ -187,11 +209,7 @@ func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User
|
||||||
Status: "Completed",
|
Status: "Completed",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
notifications, err := notificationsForUser(ctx, user)
|
userContent, err := contentForUser(ctx, user)
|
||||||
if err != nil {
|
|
||||||
respondError(w, "Failed to get notifications", err, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := ContentDashboard{
|
data := ContentDashboard{
|
||||||
CountInspections: int(inspectionCount),
|
CountInspections: int(inspectionCount),
|
||||||
CountMosquitoSources: int(sourceCount),
|
CountMosquitoSources: int(sourceCount),
|
||||||
|
|
@ -199,12 +217,7 @@ func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User
|
||||||
LastSync: lastSync,
|
LastSync: lastSync,
|
||||||
Org: org.Name.MustGet(),
|
Org: org.Name.MustGet(),
|
||||||
RecentRequests: requests,
|
RecentRequests: requests,
|
||||||
User: User{
|
User: userContent,
|
||||||
DisplayName: user.DisplayName,
|
|
||||||
Initials: extractInitials(user.DisplayName),
|
|
||||||
Notifications: notifications,
|
|
||||||
Username: user.Username,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
renderOrError(w, dashboard, data)
|
renderOrError(w, dashboard, data)
|
||||||
}
|
}
|
||||||
|
|
@ -324,6 +337,18 @@ func htmlServiceRequestUpdates(w http.ResponseWriter) {
|
||||||
renderOrError(w, serviceRequestUpdates, data)
|
renderOrError(w, serviceRequestUpdates, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func htmlSettings(w http.ResponseWriter, r *http.Request, user *models.User) {
|
||||||
|
userContent, err := contentForUser(r.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, "Failed to get user content", err, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := ContentAuthenticatedPlaceholder{
|
||||||
|
User: userContent,
|
||||||
|
}
|
||||||
|
renderOrError(w, settings, data)
|
||||||
|
}
|
||||||
|
|
||||||
func htmlSignin(w http.ResponseWriter, errorCode string) {
|
func htmlSignin(w http.ResponseWriter, errorCode string) {
|
||||||
data := ContentSignin{
|
data := ContentSignin{
|
||||||
InvalidCredentials: errorCode == "invalid-credentials",
|
InvalidCredentials: errorCode == "invalid-credentials",
|
||||||
|
|
@ -401,6 +426,7 @@ func parseFromDisk(files []string) (*template.Template, error) {
|
||||||
}
|
}
|
||||||
return templ, nil
|
return templ, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func timeElapsed(seconds null.Val[float32]) string {
|
func timeElapsed(seconds null.Val[float32]) string {
|
||||||
if !seconds.IsValue() {
|
if !seconds.IsValue() {
|
||||||
return "none"
|
return "none"
|
||||||
|
|
|
||||||
12
main.go
12
main.go
|
|
@ -69,12 +69,18 @@ func main() {
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(sessionManager.LoadAndSave)
|
r.Use(sessionManager.LoadAndSave)
|
||||||
|
|
||||||
|
// Root is a special endpoint that is neither authenticated nor unauthenticated
|
||||||
r.Get("/", getRoot)
|
r.Get("/", getRoot)
|
||||||
|
|
||||||
|
// Unauthenticated endpoints
|
||||||
r.Get("/arcgis/oauth/begin", getArcgisOauthBegin)
|
r.Get("/arcgis/oauth/begin", getArcgisOauthBegin)
|
||||||
r.Get("/arcgis/oauth/callback", getArcgisOauthCallback)
|
r.Get("/arcgis/oauth/callback", getArcgisOauthCallback)
|
||||||
r.Get("/qr-code/report/{code}", getQRCodeReport)
|
r.Get("/favicon.ico", getFavicon)
|
||||||
|
|
||||||
r.Get("/oauth/refresh", getOAuthRefresh)
|
r.Get("/oauth/refresh", getOAuthRefresh)
|
||||||
|
|
||||||
r.Get("/phone-call", getPhoneCall)
|
r.Get("/phone-call", getPhoneCall)
|
||||||
|
r.Get("/qr-code/report/{code}", getQRCodeReport)
|
||||||
r.Get("/report", getReport)
|
r.Get("/report", getReport)
|
||||||
r.Get("/report/{code}", getReportDetail)
|
r.Get("/report/{code}", getReportDetail)
|
||||||
r.Get("/report/{code}/confirm", getReportConfirmation)
|
r.Get("/report/{code}/confirm", getReportConfirmation)
|
||||||
|
|
@ -93,7 +99,9 @@ func main() {
|
||||||
r.Post("/signin", postSignin)
|
r.Post("/signin", postSignin)
|
||||||
r.Get("/signup", getSignup)
|
r.Get("/signup", getSignup)
|
||||||
r.Post("/signup", postSignup)
|
r.Post("/signup", postSignup)
|
||||||
r.Get("/favicon.ico", getFavicon)
|
|
||||||
|
// Authenticated endpoints
|
||||||
|
r.Method("GET", "/settings", NewEnsureAuth(getSettings))
|
||||||
|
|
||||||
localFS := http.Dir("./static")
|
localFS := http.Dir("./static")
|
||||||
FileServer(r, "/static", localFS, embeddedStaticFS, "static")
|
FileServer(r, "/static", localFS, embeddedStaticFS, "static")
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,7 @@
|
||||||
<span class="me-2">{{ .DisplayName }}</span>
|
<span class="me-2">{{ .DisplayName }}</span>
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
|
||||||
<li><a class="dropdown-item" href="profile.html">Profile</a></li>
|
<li><a class="dropdown-item" href="/settings">Settings</a></li>
|
||||||
<li><a class="dropdown-item" href="settings.html">Settings</a></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item text-danger" href="login.html">Logout</a></li>
|
<li><a class="dropdown-item text-danger" href="login.html">Logout</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
7
templates/empty-auth.html
Normal file
7
templates/empty-auth.html
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{{template "authenticated.html" .}}
|
||||||
|
|
||||||
|
{{define "title"}}Dash{{end}}
|
||||||
|
{{define "style"}}
|
||||||
|
{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{end}}
|
||||||
8
templates/settings.html
Normal file
8
templates/settings.html
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{{template "authenticated.html" .}}
|
||||||
|
|
||||||
|
{{define "title"}}Dash{{end}}
|
||||||
|
{{define "style"}}
|
||||||
|
{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<p>Imagine settings here</p>
|
||||||
|
{{end}}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue