mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
feat: user signup
This commit is contained in:
parent
a317dc6942
commit
abfaa9d768
84
main.go
84
main.go
@ -1,8 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/securecookie"
|
||||
@ -19,7 +17,6 @@ import (
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/rubenv/sql-migrate"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
ini "gopkg.in/ini.v1"
|
||||
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
@ -33,6 +30,19 @@ import (
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
config *models.Config
|
||||
)
|
||||
|
||||
var (
|
||||
aliasService *services.AliasService
|
||||
heartbeatService *services.HeartbeatService
|
||||
userService *services.UserService
|
||||
summaryService *services.SummaryService
|
||||
aggregationService *services.AggregationService
|
||||
)
|
||||
|
||||
// TODO: Refactor entire project to be structured after business domains
|
||||
|
||||
func readConfig() *models.Config {
|
||||
@ -125,14 +135,16 @@ func readConfig() *models.Config {
|
||||
|
||||
func main() {
|
||||
// Read Config
|
||||
config := readConfig()
|
||||
config = readConfig()
|
||||
|
||||
// Enable line numbers in logging
|
||||
if config.IsDev() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
db, err := gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
|
||||
var err error
|
||||
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
|
||||
if config.DbDialect == "sqlite3" {
|
||||
db.DB().Exec("PRAGMA foreign_keys = ON;")
|
||||
}
|
||||
@ -150,34 +162,34 @@ func main() {
|
||||
migrateDo := databaseMigrateActions(config.DbDialect)
|
||||
migrateDo(db)
|
||||
|
||||
// Custom migrations and initial data
|
||||
addDefaultUser(db, config)
|
||||
migrateLanguages(db, config)
|
||||
|
||||
// Services
|
||||
aliasSrvc := &services.AliasService{Config: config, Db: db}
|
||||
heartbeatSrvc := &services.HeartbeatService{Config: config, Db: db}
|
||||
userSrvc := &services.UserService{Config: config, Db: db}
|
||||
summarySrvc := &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatSrvc, AliasService: aliasSrvc}
|
||||
aggregationSrvc := &services.AggregationService{Config: config, Db: db, UserService: userSrvc, SummaryService: summarySrvc, HeartbeatService: heartbeatSrvc}
|
||||
aliasService = &services.AliasService{Config: config, Db: db}
|
||||
heartbeatService = &services.HeartbeatService{Config: config, Db: db}
|
||||
userService = &services.UserService{Config: config, Db: db}
|
||||
summaryService = &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatService, AliasService: aliasService}
|
||||
aggregationService = &services.AggregationService{Config: config, Db: db, UserService: userService, SummaryService: summaryService, HeartbeatService: heartbeatService}
|
||||
|
||||
svcs := []services.Initializable{aliasSrvc, heartbeatSrvc, summarySrvc, userSrvc, aggregationSrvc}
|
||||
svcs := []services.Initializable{aliasService, heartbeatService, userService, summaryService, aggregationService}
|
||||
for _, s := range svcs {
|
||||
s.Init()
|
||||
}
|
||||
|
||||
// Custom migrations and initial data
|
||||
addDefaultUser()
|
||||
migrateLanguages()
|
||||
|
||||
// Aggregate heartbeats to summaries and persist them
|
||||
go aggregationSrvc.Schedule()
|
||||
go aggregationService.Schedule()
|
||||
|
||||
if config.CleanUp {
|
||||
go heartbeatSrvc.ScheduleCleanUp()
|
||||
go heartbeatService.ScheduleCleanUp()
|
||||
}
|
||||
|
||||
// Handlers
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(config, heartbeatSrvc)
|
||||
summaryHandler := routes.NewSummaryHandler(config, summarySrvc)
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(config, heartbeatService)
|
||||
summaryHandler := routes.NewSummaryHandler(config, summaryService)
|
||||
healthHandler := routes.NewHealthHandler(db)
|
||||
indexHandler := routes.NewIndexHandler(config, userSrvc)
|
||||
indexHandler := routes.NewIndexHandler(config, userService)
|
||||
|
||||
// Setup Routers
|
||||
router := mux.NewRouter()
|
||||
@ -191,7 +203,7 @@ func main() {
|
||||
corsMiddleware := handlers.CORS()
|
||||
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
|
||||
config,
|
||||
userSrvc,
|
||||
userService,
|
||||
[]string{"/api/health"},
|
||||
).Handler
|
||||
|
||||
@ -204,6 +216,7 @@ func main() {
|
||||
indexRouter.Path("/").Methods(http.MethodGet).HandlerFunc(indexHandler.Index)
|
||||
indexRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(indexHandler.Login)
|
||||
indexRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(indexHandler.Logout)
|
||||
indexRouter.Path("/signup").Methods(http.MethodGet, http.MethodPost).HandlerFunc(indexHandler.Signup)
|
||||
|
||||
// Summary Routes
|
||||
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.Index)
|
||||
@ -254,8 +267,8 @@ func databaseMigrateActions(dbDialect string) func(db *gorm.DB) {
|
||||
return migrateDo
|
||||
}
|
||||
|
||||
func migrateLanguages(db *gorm.DB, cfg *models.Config) {
|
||||
for k, v := range cfg.CustomLanguages {
|
||||
func migrateLanguages() {
|
||||
for k, v := range config.CustomLanguages {
|
||||
result := db.Model(models.Heartbeat{}).
|
||||
Where("language = ?", "").
|
||||
Where("entity LIKE ?", "%."+k).
|
||||
@ -269,17 +282,18 @@ func migrateLanguages(db *gorm.DB, cfg *models.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
func addDefaultUser(db *gorm.DB, cfg *models.Config) {
|
||||
pw := md5.Sum([]byte(cfg.DefaultUserPassword))
|
||||
pwString := hex.EncodeToString(pw[:])
|
||||
apiKey := uuid.NewV4().String()
|
||||
u := &models.User{ID: cfg.DefaultUserName, Password: pwString, ApiKey: apiKey}
|
||||
result := db.FirstOrCreate(u, &models.User{ID: u.ID})
|
||||
if result.Error != nil {
|
||||
log.Println("Unable to create default user.")
|
||||
log.Fatal(result.Error)
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("Created default user '%s' with password '%s' and API key '%s'.\n", u.ID, cfg.DefaultUserPassword, u.ApiKey)
|
||||
func addDefaultUser() {
|
||||
u, created, err := userService.CreateOrGet(&models.Signup{
|
||||
Username: config.DefaultUserName,
|
||||
Password: config.DefaultUserPassword,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Println("unable to create default user")
|
||||
log.Fatal(err)
|
||||
} else if created {
|
||||
log.Printf("created default user '%s' with password '%s' and API key '%s'\n", u.ID, config.DefaultUserPassword, u.ApiKey)
|
||||
} else {
|
||||
log.Printf("default user '%s' already existing\n", u.ID)
|
||||
}
|
||||
}
|
||||
|
@ -40,4 +40,5 @@ type SummaryViewModel struct {
|
||||
*Summary
|
||||
LanguageColors map[string]string
|
||||
Error string
|
||||
Success string
|
||||
}
|
||||
|
@ -7,6 +7,12 @@ type User struct {
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Username string `schema:"username"`
|
||||
Password string `schema:"password"`
|
||||
}
|
||||
|
||||
type Signup struct {
|
||||
Username string `schema:"username"`
|
||||
Password string `schema:"password"`
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
}
|
||||
|
136
routes/index.go
136
routes/index.go
@ -1,11 +1,13 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/schema"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type IndexHandler struct {
|
||||
@ -14,6 +16,7 @@ type IndexHandler struct {
|
||||
}
|
||||
|
||||
var loginDecoder = schema.NewDecoder()
|
||||
var signupDecoder = schema.NewDecoder()
|
||||
|
||||
func NewIndexHandler(config *models.Config, userService *services.UserService) *IndexHandler {
|
||||
return &IndexHandler{
|
||||
@ -32,15 +35,18 @@ func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.URL.Query().Get("error"); err != "" {
|
||||
if err == "unauthorized" {
|
||||
respondError(w, err, http.StatusUnauthorized)
|
||||
} else {
|
||||
respondError(w, err, http.StatusInternalServerError)
|
||||
}
|
||||
if handleAlerts(w, r, "") {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: make this more generic and reusable
|
||||
if success := r.URL.Query().Get("success"); success != "" {
|
||||
templates["index.tpl.html"].Execute(w, struct {
|
||||
Success string
|
||||
Error string
|
||||
}{Success: success})
|
||||
return
|
||||
}
|
||||
templates["index.tpl.html"].Execute(w, nil)
|
||||
}
|
||||
|
||||
@ -49,30 +55,35 @@ func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, "/summary", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var login models.Login
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondError(w, "missing parameters", http.StatusBadRequest)
|
||||
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
||||
respondError(w, "missing parameters", http.StatusBadRequest)
|
||||
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSrvc.GetUserById(login.Username)
|
||||
if err != nil {
|
||||
respondError(w, "resource not found", http.StatusNotFound)
|
||||
respondAlert(w, "resource not found", "", "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPassword(user, login.Password) {
|
||||
respondError(w, "invalid credentials", http.StatusUnauthorized)
|
||||
respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
if err != nil {
|
||||
respondError(w, "internal server error", http.StatusInternalServerError)
|
||||
respondAlert(w, "internal server error", "", "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@ -96,9 +107,108 @@ func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func respondError(w http.ResponseWriter, error string, status int) {
|
||||
func (h *IndexHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, "/summary", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handlePostSignup(w, r)
|
||||
return
|
||||
default:
|
||||
h.handleGetSignup(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, "/summary", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if handleAlerts(w, r, "signup.tpl.html") {
|
||||
return
|
||||
}
|
||||
|
||||
templates["signup.tpl.html"].Execute(w, nil)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) handlePostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
||||
http.Redirect(w, r, "/summary", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var signup models.Signup
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if signup.Password != signup.PasswordRepeat {
|
||||
respondAlert(w, "passwords do not match", "", "signup.tpl.html", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, created, err := h.userSrvc.CreateOrGet(&signup)
|
||||
if err != nil {
|
||||
respondAlert(w, "failed to create new user", "", "signup.tpl.html", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape("account created successfully")
|
||||
http.Redirect(w, r, fmt.Sprintf("/?success=%s", msg), http.StatusFound)
|
||||
}
|
||||
|
||||
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
|
||||
w.WriteHeader(status)
|
||||
templates["index.tpl.html"].Execute(w, struct {
|
||||
if tplName == "" {
|
||||
tplName = "index.tpl.html"
|
||||
}
|
||||
templates[tplName].Execute(w, struct {
|
||||
Error string
|
||||
Success string
|
||||
}{Error: error})
|
||||
}
|
||||
|
||||
// TODO: do better
|
||||
func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool {
|
||||
if err := r.URL.Query().Get("error"); err != "" {
|
||||
if err == "unauthorized" {
|
||||
respondAlert(w, err, "", tplName, http.StatusUnauthorized)
|
||||
} else {
|
||||
respondAlert(w, err, "", tplName, http.StatusInternalServerError)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if success := r.URL.Query().Get("success"); success != "" {
|
||||
respondAlert(w, "", success, tplName, http.StatusOK)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@ -19,6 +20,8 @@ func loadTemplates() {
|
||||
tpls := template.New("").Funcs(template.FuncMap{
|
||||
"json": utils.Json,
|
||||
"date": utils.FormatDateHuman,
|
||||
"title": strings.Title,
|
||||
"capitalize": utils.Capitalize,
|
||||
})
|
||||
templates = make(map[string]*template.Template)
|
||||
|
@ -1,8 +1,11 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/muety/wakapi/models"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
@ -37,3 +40,26 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
|
||||
pw := md5.Sum([]byte(signup.Password))
|
||||
pwString := hex.EncodeToString(pw[:])
|
||||
apiKey := uuid.NewV4().String()
|
||||
|
||||
u := &models.User{
|
||||
ID: signup.Username,
|
||||
ApiKey: apiKey,
|
||||
Password: pwString,
|
||||
}
|
||||
|
||||
result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID})
|
||||
if err := result.Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if result.RowsAffected == 1 {
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
return u, false, nil
|
||||
}
|
||||
|
10
utils/strings.go
Normal file
10
utils/strings.go
Normal file
@ -0,0 +1,10 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Capitalize(s string) string {
|
||||
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
|
||||
}
|
@ -1,7 +1,13 @@
|
||||
{{ if .Error }}
|
||||
<div class="flex justify-center w-full">
|
||||
<div class="p-4 text-white text-sm bg-red-500 rounded mt-16 shadow flex-grow max-w-lg">
|
||||
Error: {{ .Error }}
|
||||
Error: {{ .Error | capitalize }}
|
||||
</div>
|
||||
</div>
|
||||
{{ else if .Success }}
|
||||
<div class="flex justify-center w-full">
|
||||
<div class="p-4 text-white text-sm bg-green-500 rounded mt-16 shadow flex-grow max-w-lg">
|
||||
{{ .Success | capitalize }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
@ -14,16 +14,20 @@
|
||||
<form action="/login" method="post">
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="text" id="username"
|
||||
name="username" placeholder="Enter your username ..." required>
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||
type="text" id="username"
|
||||
name="username" placeholder="Enter your username" required>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="password" id="password"
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||
type="password" id="password"
|
||||
name="password" placeholder="******" required>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<a href="/signup">
|
||||
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
|
||||
</a>
|
||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
|
||||
</div>
|
||||
</form>
|
||||
|
42
views/signup.tpl.html
Normal file
42
views/signup.tpl.html
Normal file
@ -0,0 +1,42 @@
|
||||
<html>
|
||||
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
<div class="flex items-center justify-center">
|
||||
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1>
|
||||
</div>
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full" id="grid-container">
|
||||
<div class="flex-grow max-w-lg mt-12">
|
||||
<form action="/signup" method="post">
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="text" id="username"
|
||||
name="username" placeholder="Choose a username" required>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="password" id="password"
|
||||
name="password" placeholder="Choose a password" required>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password">And again ...</label>
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="password" id="password_repeat"
|
||||
name="password_repeat" placeholder="Repeat your password" required>
|
||||
</div>
|
||||
<div class="flex justify-between float-right">
|
||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Create Account</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
||||
{{ template "foot.tpl.html" . }}
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user